diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2b503786a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,21 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## 어떤 버그인가요 + +> 문제가 되는 부분에 대해 설명해주세요 + +## 재현 방법(선택) +버그를 재현할 수 있는 과정을 설명해주세요(필요하다면 사진을 첨부해주세요) +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +## 참고할만한 자료(선택) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..1b6ed9260 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature +assignees: '' + +--- + +## 어떤 기능인가요? + +> 추가하려는 기능에 대해 간결하게 설명해주세요 + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/.github/ISSUE_TEMPLATE/refactor_request.md b/.github/ISSUE_TEMPLATE/refactor_request.md new file mode 100644 index 000000000..38aa8ef03 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor_request.md @@ -0,0 +1,28 @@ +--- +name: Refactor request +about: Suggest an refactor for this project +title: '' +labels: refactor +assignees: '' + +--- + +## 어떤 부분을 리팩터링하려 하나요? + +> 리팩터링하려는 부분에 대해 간결하게 설명해주세요 + +### AS-IS +- as-is +- as-is + +### TO-BE +- to-be +- to-be + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f039ae72b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## 관련 이슈 + +- resolves: #이슈 번호 + +## 작업 내용 + + + + + +## 특이 사항 + + + +## 리뷰 요구사항 (선택) + + + + + + diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml new file mode 100644 index 000000000..9030c80f5 --- /dev/null +++ b/.github/workflows/prod-cd.yml @@ -0,0 +1,75 @@ +name: "[PROD] Build Gradle and Deploy" + +on: + push: + branches: [ "master" ] + workflow_dispatch: + +jobs: + build-gradle: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + with: + token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Grant execute permission for Gradle wrapper(gradlew) + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew bootJar + + - name: Copy jar file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + source: "./build/libs/*.jar" + target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" + + - name: Copy docker file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + source: "./Dockerfile" + target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" + + - name: Copy docker compose file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + source: "./docker-compose.prod.yml" + target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" + + - name: Run docker compose + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + script_stop: true + script: | + cd /home/${{ secrets.USERNAME }}/solid-connect-server + docker compose down + docker compose -f docker-compose.prod.yml up -d --build diff --git a/.github/workflows/stage-cd.yml b/.github/workflows/stage-cd.yml new file mode 100644 index 000000000..41ff68b37 --- /dev/null +++ b/.github/workflows/stage-cd.yml @@ -0,0 +1,75 @@ +name: "[STAGE] Build Gradle and Deploy" + +on: + push: + branches: [ "release" ] + workflow_dispatch: + +jobs: + build-gradle: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + with: + token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Grant execute permission for Gradle wrapper(gradlew) + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew bootJar + + - name: Copy jar file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.STAGE_HOST }} + username: ${{ secrets.STAGE_USERNAME }} + key: ${{ secrets.STAGE_PRIVATE_KEY }} + source: "./build/libs/*.jar" + target: "/home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage/" + + - name: Copy docker file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.STAGE_HOST }} + username: ${{ secrets.STAGE_USERNAME }} + key: ${{ secrets.STAGE_PRIVATE_KEY }} + source: "./Dockerfile" + target: "/home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage/" + + - name: Copy docker compose file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.STAGE_HOST }} + username: ${{ secrets.STAGE_USERNAME }} + key: ${{ secrets.STAGE_PRIVATE_KEY }} + source: "./docker-compose.stage.yml" + target: "/home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage/" + + - name: Run docker compose + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.STAGE_HOST }} + username: ${{ secrets.STAGE_USERNAME }} + key: ${{ secrets.STAGE_PRIVATE_KEY }} + script_stop: true + script: | + cd /home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage + docker compose down + docker compose -f docker-compose.stage.yml up -d --build diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9f59fa8d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +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/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### YML ### +application-secret.yml +application-prod.yml + +### docker volumes ### +mysql_data_local +redis_data_local diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..a7033a673 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/main/resources/secret"] + path = src/main/resources/secret + url = https://github.com/solid-connection/solid-connect-secret diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..773d1ba16 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# JDK 버전 설정 +FROM openjdk:17-jdk + +# JAR_FILE 변수 정의 +ARG JAR_FILE=./build/libs/solid-connection-0.0.1-SNAPSHOT.jar + +# JAR 파일 메인 디렉토리에 복사 +COPY ${JAR_FILE} app.jar + +# 시스템 진입점 정의 +ENTRYPOINT ["java", "-jar", "/app.jar"] + +# 볼륨 설정 +VOLUME /tmp diff --git a/README.md b/README.md index 6bfa33a00..a07f6d77b 100644 Binary files a/README.md and b/README.md differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..24f5e41c4 --- /dev/null +++ b/build.gradle @@ -0,0 +1,78 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.5' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies {//todo: 안쓰는 의존성이나 deprecated된 의존성 제거 + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.mysql:mysql-connector-j:8.2.0' + implementation 'org.hibernate:hibernate-core:6.3.0.CR1' + implementation 'org.springframework.data:spring-data-redis:3.1.2' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'org.springframework.security:spring-security-core:6.1.2' + implementation 'org.springframework.security:spring-security-config:6.1.2' + implementation 'org.springframework.security:spring-security-web:6.1.2' + implementation 'io.lettuce:lettuce-core:6.2.5.RELEASE' + implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.470' + implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final' + implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1' + implementation 'org.apache.commons:commons-lang3:3.12.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + + // Lombok + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mockito:mockito-core:3.3.3' + testImplementation 'io.rest-assured:rest-assured:5.4.0' + + // Testcontainers + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + + annotationProcessor( + 'com.querydsl:querydsl-apt:5.0.0:jakarta', + 'jakarta.persistence:jakarta.persistence-api:3.1.0', + 'jakarta.annotation:jakarta.annotation-api:2.1.1' + ) + + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' +} + +tasks.named('test') { + useJUnitPlatform() +} + +sourceSets { + main.java.srcDirs += ['build/generated/sources/annotationProcessor/java/main'] +} + +compileJava { + options.annotationProcessorGeneratedSourcesDirectory = file('build/generated/sources/annotationProcessor/java/main') +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 000000000..07fd75023 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,21 @@ +services: + mysql: + image: mysql:8.0 + container_name: solid-connection-local-mysql + environment: + MYSQL_ROOT_PASSWORD: solid_connection_local_root_password + MYSQL_DATABASE: solid_connection + MYSQL_USER: solid_connection_local_username + MYSQL_PASSWORD: solid_connection_local_password + ports: + - "3306:3306" + volumes: + - ./mysql_data_local:/var/lib/mysql + + redis: + image: redis:latest + container_name: solid-connection-local-redis + ports: + - "6379:6379" + volumes: + - ./redis_data_local:/data diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 000000000..9517a07aa --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + + redis-exporter: + image: oliver006/redis_exporter + container_name: redis-exporter + ports: + - "9121:9121" + environment: + REDIS_ADDR: "redis:6379" + depends_on: + - redis + + solid-connection-server: + build: + context: . + dockerfile: Dockerfile + container_name: solid-connection-server + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_PORT=6379 + depends_on: + - redis diff --git a/docker-compose.stage.yml b/docker-compose.stage.yml new file mode 100644 index 000000000..3a97a6411 --- /dev/null +++ b/docker-compose.stage.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + network_mode: host + + redis-exporter: + image: oliver006/redis_exporter + container_name: redis-exporter + ports: + - "9121:9121" + environment: + REDIS_ADDR: "localhost:6379" + depends_on: + - redis + network_mode: host + + solid-connection-stage: + build: + context: . + dockerfile: Dockerfile + container_name: solid-connection-stage + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=stage + depends_on: + - redis + network_mode: host diff --git a/docs/nginx.conf b/docs/nginx.conf new file mode 100644 index 000000000..303463bce --- /dev/null +++ b/docs/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + +# http를 사용하는 경우 주석 해제 +# location / { +# proxy_pass http://solid-connection-server:8080; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + + ssl_certificate /etc/letsencrypt/live/api.solid-connection.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.solid-connection.com/privkey.pem; + client_max_body_size 10M; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; # 클라이언트 보다 서버의 암호화 알고리즘을 우선하도록 설정 + ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"; + ssl_session_cache shared:SSL:10m; # SSL 세션 캐시 설정 + ssl_session_timeout 10m; + ssl_stapling on; # OCSP 스테이플링 활성화 + ssl_stapling_verify on; + + location / { + proxy_pass http://solid-connection-server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d64cd4917 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 000000000..1af9e0930 --- /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.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..1aa94a426 --- /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/subprojects/plugins/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 000000000..93e3f59f1 --- /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. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +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/local_compose_down.sh b/local_compose_down.sh new file mode 100755 index 000000000..32792e490 --- /dev/null +++ b/local_compose_down.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +echo "Stopping all docker containers..." +docker compose -f docker-compose.local.yml down + +echo "Pruning unused Docker images..." +docker image prune -f + +echo "Containers are down and not running." +docker compose ps -a diff --git a/local_compose_up.sh b/local_compose_up.sh new file mode 100755 index 000000000..400861e57 --- /dev/null +++ b/local_compose_up.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# 명령이 0이 아닌 종료값을 가질때 즉시 종료 +set -e + +if [ ! -d "mysql_data_local" ]; then + echo "mysql_data_local 디렉토리가 없습니다. 디렉토리를 생성합니다." + mkdir -p mysql_data_local +fi + +if [ ! -d "redis_data_local" ]; then + echo "redis_data_local 디렉토리가 없습니다. 디렉토리를 생성합니다." + mkdir -p redis_data_local +fi + +echo "Starting all docker containers..." +docker compose -f docker-compose.local.yml up -d + +echo "Pruning unused Docker images..." +docker image prune -f + +echo "Containers are up and running." +docker compose ps -a diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..ab5896b5d --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'solid-connection' diff --git a/src/main/generated/com/example/solidconnection/application/domain/QApplication.java b/src/main/generated/com/example/solidconnection/application/domain/QApplication.java new file mode 100644 index 000000000..742603411 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/application/domain/QApplication.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.application.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QApplication is a Querydsl query type for Application + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QApplication extends EntityPathBase { + + private static final long serialVersionUID = -122324166L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QApplication application = new QApplication("application"); + + public final com.example.solidconnection.university.domain.QUniversityInfoForApply firstChoiceUniversity; + + public final QGpa gpa; + + public final NumberPath id = createNumber("id", Long.class); + + public final QLanguageTest languageTest; + + public final StringPath nicknameForApply = createString("nicknameForApply"); + + public final com.example.solidconnection.university.domain.QUniversityInfoForApply secondChoiceUniversity; + + public final com.example.solidconnection.siteuser.domain.QSiteUser siteUser; + + public final NumberPath updateCount = createNumber("updateCount", Integer.class); + + public final EnumPath verifyStatus = createEnum("verifyStatus", com.example.solidconnection.type.VerifyStatus.class); + + public QApplication(String variable) { + this(Application.class, forVariable(variable), INITS); + } + + public QApplication(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QApplication(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QApplication(PathMetadata metadata, PathInits inits) { + this(Application.class, metadata, inits); + } + + public QApplication(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.firstChoiceUniversity = inits.isInitialized("firstChoiceUniversity") ? new com.example.solidconnection.university.domain.QUniversityInfoForApply(forProperty("firstChoiceUniversity"), inits.get("firstChoiceUniversity")) : null; + this.gpa = inits.isInitialized("gpa") ? new QGpa(forProperty("gpa")) : null; + this.languageTest = inits.isInitialized("languageTest") ? new QLanguageTest(forProperty("languageTest")) : null; + this.secondChoiceUniversity = inits.isInitialized("secondChoiceUniversity") ? new com.example.solidconnection.university.domain.QUniversityInfoForApply(forProperty("secondChoiceUniversity"), inits.get("secondChoiceUniversity")) : null; + this.siteUser = inits.isInitialized("siteUser") ? new com.example.solidconnection.siteuser.domain.QSiteUser(forProperty("siteUser")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/application/domain/QGpa.java b/src/main/generated/com/example/solidconnection/application/domain/QGpa.java new file mode 100644 index 000000000..e6f55fd97 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/application/domain/QGpa.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.application.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QGpa is a Querydsl query type for Gpa + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QGpa extends BeanPath { + + private static final long serialVersionUID = -51081086L; + + public static final QGpa gpa1 = new QGpa("gpa1"); + + public final NumberPath gpa = createNumber("gpa", Double.class); + + public final NumberPath gpaCriteria = createNumber("gpaCriteria", Double.class); + + public final StringPath gpaReportUrl = createString("gpaReportUrl"); + + public QGpa(String variable) { + super(Gpa.class, forVariable(variable)); + } + + public QGpa(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QGpa(PathMetadata metadata) { + super(Gpa.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/solidconnection/application/domain/QLanguageTest.java b/src/main/generated/com/example/solidconnection/application/domain/QLanguageTest.java new file mode 100644 index 000000000..d22d113d2 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/application/domain/QLanguageTest.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.application.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QLanguageTest is a Querydsl query type for LanguageTest + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QLanguageTest extends BeanPath { + + private static final long serialVersionUID = 1768826720L; + + public static final QLanguageTest languageTest = new QLanguageTest("languageTest"); + + public final StringPath languageTestReportUrl = createString("languageTestReportUrl"); + + public final StringPath languageTestScore = createString("languageTestScore"); + + public final EnumPath languageTestType = createEnum("languageTestType", com.example.solidconnection.type.LanguageTestType.class); + + public QLanguageTest(String variable) { + super(LanguageTest.class, forVariable(variable)); + } + + public QLanguageTest(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QLanguageTest(PathMetadata metadata) { + super(LanguageTest.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/solidconnection/entity/QCountry.java b/src/main/generated/com/example/solidconnection/entity/QCountry.java new file mode 100644 index 000000000..ab42f8004 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/entity/QCountry.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QCountry is a Querydsl query type for Country + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QCountry extends EntityPathBase { + + private static final long serialVersionUID = -2001953983L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QCountry country = new QCountry("country"); + + public final StringPath code = createString("code"); + + public final StringPath koreanName = createString("koreanName"); + + public final QRegion region; + + public QCountry(String variable) { + this(Country.class, forVariable(variable), INITS); + } + + public QCountry(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QCountry(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QCountry(PathMetadata metadata, PathInits inits) { + this(Country.class, metadata, inits); + } + + public QCountry(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.region = inits.isInitialized("region") ? new QRegion(forProperty("region")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/entity/QInterestedCountry.java b/src/main/generated/com/example/solidconnection/entity/QInterestedCountry.java new file mode 100644 index 000000000..9c8b3c6d0 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/entity/QInterestedCountry.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QInterestedCountry is a Querydsl query type for InterestedCountry + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QInterestedCountry extends EntityPathBase { + + private static final long serialVersionUID = 1105130488L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QInterestedCountry interestedCountry = new QInterestedCountry("interestedCountry"); + + public final QCountry country; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.solidconnection.siteuser.domain.QSiteUser siteUser; + + public QInterestedCountry(String variable) { + this(InterestedCountry.class, forVariable(variable), INITS); + } + + public QInterestedCountry(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QInterestedCountry(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QInterestedCountry(PathMetadata metadata, PathInits inits) { + this(InterestedCountry.class, metadata, inits); + } + + public QInterestedCountry(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.country = inits.isInitialized("country") ? new QCountry(forProperty("country"), inits.get("country")) : null; + this.siteUser = inits.isInitialized("siteUser") ? new com.example.solidconnection.siteuser.domain.QSiteUser(forProperty("siteUser")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/entity/QInterestedRegion.java b/src/main/generated/com/example/solidconnection/entity/QInterestedRegion.java new file mode 100644 index 000000000..b60554f01 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/entity/QInterestedRegion.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QInterestedRegion is a Querydsl query type for InterestedRegion + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QInterestedRegion extends EntityPathBase { + + private static final long serialVersionUID = -1345685934L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QInterestedRegion interestedRegion = new QInterestedRegion("interestedRegion"); + + public final NumberPath id = createNumber("id", Long.class); + + public final QRegion region; + + public final com.example.solidconnection.siteuser.domain.QSiteUser siteUser; + + public QInterestedRegion(String variable) { + this(InterestedRegion.class, forVariable(variable), INITS); + } + + public QInterestedRegion(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QInterestedRegion(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QInterestedRegion(PathMetadata metadata, PathInits inits) { + this(InterestedRegion.class, metadata, inits); + } + + public QInterestedRegion(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.region = inits.isInitialized("region") ? new QRegion(forProperty("region")) : null; + this.siteUser = inits.isInitialized("siteUser") ? new com.example.solidconnection.siteuser.domain.QSiteUser(forProperty("siteUser")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/entity/QRegion.java b/src/main/generated/com/example/solidconnection/entity/QRegion.java new file mode 100644 index 000000000..23bfeafb4 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/entity/QRegion.java @@ -0,0 +1,39 @@ +package com.example.solidconnection.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QRegion is a Querydsl query type for Region + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QRegion extends EntityPathBase { + + private static final long serialVersionUID = 1047937513L; + + public static final QRegion region = new QRegion("region"); + + public final StringPath code = createString("code"); + + public final StringPath koreanName = createString("koreanName"); + + public QRegion(String variable) { + super(Region.class, forVariable(variable)); + } + + public QRegion(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QRegion(PathMetadata metadata) { + super(Region.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/solidconnection/siteuser/domain/QSiteUser.java b/src/main/generated/com/example/solidconnection/siteuser/domain/QSiteUser.java new file mode 100644 index 000000000..a1879a555 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/siteuser/domain/QSiteUser.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.siteuser.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QSiteUser is a Querydsl query type for SiteUser + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSiteUser extends EntityPathBase { + + private static final long serialVersionUID = 1080517302L; + + public static final QSiteUser siteUser = new QSiteUser("siteUser"); + + public final StringPath birth = createString("birth"); + + public final StringPath email = createString("email"); + + public final EnumPath gender = createEnum("gender", com.example.solidconnection.type.Gender.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath nickname = createString("nickname"); + + public final DateTimePath nicknameModifiedAt = createDateTime("nicknameModifiedAt", java.time.LocalDateTime.class); + + public final EnumPath preparationStage = createEnum("preparationStage", com.example.solidconnection.type.PreparationStatus.class); + + public final StringPath profileImageUrl = createString("profileImageUrl"); + + public final DatePath quitedAt = createDate("quitedAt", java.time.LocalDate.class); + + public final EnumPath role = createEnum("role", com.example.solidconnection.type.Role.class); + + public QSiteUser(String variable) { + super(SiteUser.class, forVariable(variable)); + } + + public QSiteUser(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QSiteUser(PathMetadata metadata) { + super(SiteUser.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/solidconnection/university/domain/QLanguageRequirement.java b/src/main/generated/com/example/solidconnection/university/domain/QLanguageRequirement.java new file mode 100644 index 000000000..b5b49ddeb --- /dev/null +++ b/src/main/generated/com/example/solidconnection/university/domain/QLanguageRequirement.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.university.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QLanguageRequirement is a Querydsl query type for LanguageRequirement + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QLanguageRequirement extends EntityPathBase { + + private static final long serialVersionUID = 443667787L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QLanguageRequirement languageRequirement = new QLanguageRequirement("languageRequirement"); + + public final NumberPath id = createNumber("id", Long.class); + + public final EnumPath languageTestType = createEnum("languageTestType", com.example.solidconnection.type.LanguageTestType.class); + + public final StringPath minScore = createString("minScore"); + + public final QUniversityInfoForApply universityInfoForApply; + + public QLanguageRequirement(String variable) { + this(LanguageRequirement.class, forVariable(variable), INITS); + } + + public QLanguageRequirement(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QLanguageRequirement(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QLanguageRequirement(PathMetadata metadata, PathInits inits) { + this(LanguageRequirement.class, metadata, inits); + } + + public QLanguageRequirement(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.universityInfoForApply = inits.isInitialized("universityInfoForApply") ? new QUniversityInfoForApply(forProperty("universityInfoForApply"), inits.get("universityInfoForApply")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/university/domain/QLikedUniversity.java b/src/main/generated/com/example/solidconnection/university/domain/QLikedUniversity.java new file mode 100644 index 000000000..e82fc8c65 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/university/domain/QLikedUniversity.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.university.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QLikedUniversity is a Querydsl query type for LikedUniversity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QLikedUniversity extends EntityPathBase { + + private static final long serialVersionUID = 142590363L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QLikedUniversity likedUniversity = new QLikedUniversity("likedUniversity"); + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.solidconnection.siteuser.domain.QSiteUser siteUser; + + public final QUniversityInfoForApply universityInfoForApply; + + public QLikedUniversity(String variable) { + this(LikedUniversity.class, forVariable(variable), INITS); + } + + public QLikedUniversity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QLikedUniversity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QLikedUniversity(PathMetadata metadata, PathInits inits) { + this(LikedUniversity.class, metadata, inits); + } + + public QLikedUniversity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.siteUser = inits.isInitialized("siteUser") ? new com.example.solidconnection.siteuser.domain.QSiteUser(forProperty("siteUser")) : null; + this.universityInfoForApply = inits.isInitialized("universityInfoForApply") ? new QUniversityInfoForApply(forProperty("universityInfoForApply"), inits.get("universityInfoForApply")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/university/domain/QUniversity.java b/src/main/generated/com/example/solidconnection/university/domain/QUniversity.java new file mode 100644 index 000000000..fee1f2ca4 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/university/domain/QUniversity.java @@ -0,0 +1,72 @@ +package com.example.solidconnection.university.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QUniversity is a Querydsl query type for University + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QUniversity extends EntityPathBase { + + private static final long serialVersionUID = 1195314958L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QUniversity university = new QUniversity("university"); + + public final StringPath accommodationUrl = createString("accommodationUrl"); + + public final StringPath backgroundImageUrl = createString("backgroundImageUrl"); + + public final com.example.solidconnection.entity.QCountry country; + + public final StringPath detailsForLocal = createString("detailsForLocal"); + + public final StringPath englishCourseUrl = createString("englishCourseUrl"); + + public final StringPath englishName = createString("englishName"); + + public final StringPath formatName = createString("formatName"); + + public final StringPath homepageUrl = createString("homepageUrl"); + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath koreanName = createString("koreanName"); + + public final StringPath logoImageUrl = createString("logoImageUrl"); + + public final com.example.solidconnection.entity.QRegion region; + + public QUniversity(String variable) { + this(University.class, forVariable(variable), INITS); + } + + public QUniversity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QUniversity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QUniversity(PathMetadata metadata, PathInits inits) { + this(University.class, metadata, inits); + } + + public QUniversity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.country = inits.isInitialized("country") ? new com.example.solidconnection.entity.QCountry(forProperty("country"), inits.get("country")) : null; + this.region = inits.isInitialized("region") ? new com.example.solidconnection.entity.QRegion(forProperty("region")) : null; + } + +} + diff --git a/src/main/generated/com/example/solidconnection/university/domain/QUniversityInfoForApply.java b/src/main/generated/com/example/solidconnection/university/domain/QUniversityInfoForApply.java new file mode 100644 index 000000000..5ad64cfd7 --- /dev/null +++ b/src/main/generated/com/example/solidconnection/university/domain/QUniversityInfoForApply.java @@ -0,0 +1,79 @@ +package com.example.solidconnection.university.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QUniversityInfoForApply is a Querydsl query type for UniversityInfoForApply + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QUniversityInfoForApply extends EntityPathBase { + + private static final long serialVersionUID = 31331617L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QUniversityInfoForApply universityInfoForApply = new QUniversityInfoForApply("universityInfoForApply"); + + public final StringPath details = createString("details"); + + public final StringPath detailsForAccommodation = createString("detailsForAccommodation"); + + public final StringPath detailsForApply = createString("detailsForApply"); + + public final StringPath detailsForEnglishCourse = createString("detailsForEnglishCourse"); + + public final StringPath detailsForLanguage = createString("detailsForLanguage"); + + public final StringPath detailsForMajor = createString("detailsForMajor"); + + public final StringPath gpaRequirement = createString("gpaRequirement"); + + public final StringPath gpaRequirementCriteria = createString("gpaRequirementCriteria"); + + public final NumberPath id = createNumber("id", Long.class); + + public final SetPath languageRequirements = this.createSet("languageRequirements", LanguageRequirement.class, QLanguageRequirement.class, PathInits.DIRECT2); + + public final EnumPath semesterAvailableForDispatch = createEnum("semesterAvailableForDispatch", com.example.solidconnection.type.SemesterAvailableForDispatch.class); + + public final StringPath semesterRequirement = createString("semesterRequirement"); + + public final NumberPath studentCapacity = createNumber("studentCapacity", Integer.class); + + public final StringPath term = createString("term"); + + public final EnumPath tuitionFeeType = createEnum("tuitionFeeType", com.example.solidconnection.type.TuitionFeeType.class); + + public final QUniversity university; + + public QUniversityInfoForApply(String variable) { + this(UniversityInfoForApply.class, forVariable(variable), INITS); + } + + public QUniversityInfoForApply(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QUniversityInfoForApply(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QUniversityInfoForApply(PathMetadata metadata, PathInits inits) { + this(UniversityInfoForApply.class, metadata, inits); + } + + public QUniversityInfoForApply(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.university = inits.isInitialized("university") ? new QUniversity(forProperty("university"), inits.get("university")) : null; + } + +} + diff --git a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java new file mode 100644 index 000000000..a7f0554a3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java @@ -0,0 +1,21 @@ +package com.example.solidconnection; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +@ConfigurationPropertiesScan +@EnableScheduling +@EnableJpaAuditing +@EnableCaching +@SpringBootApplication +public class SolidConnectionApplication { + + public static void main(String[] args) { + SpringApplication.run(SolidConnectionApplication.class, args); + } + +} diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java new file mode 100644 index 000000000..892597e90 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.application.controller; + +import com.example.solidconnection.application.dto.ApplicationSubmissionResponse; +import com.example.solidconnection.application.dto.ApplicationsResponse; +import com.example.solidconnection.application.dto.ApplyRequest; +import com.example.solidconnection.application.service.ApplicationQueryService; +import com.example.solidconnection.application.service.ApplicationSubmissionService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RequiredArgsConstructor +@RequestMapping("/applications") +@RestController +public class ApplicationController { + + private final ApplicationSubmissionService applicationSubmissionService; + private final ApplicationQueryService applicationQueryService; + + // 지원서 제출하기 api + @PostMapping + public ResponseEntity apply( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody ApplyRequest applyRequest + ) { + boolean result = applicationSubmissionService.apply(siteUser, applyRequest); + return ResponseEntity + .status(HttpStatus.OK) + .body(new ApplicationSubmissionResponse(result)); + } + + @GetMapping + public ResponseEntity getApplicants( + @AuthorizedUser SiteUser siteUser, + @RequestParam(required = false, defaultValue = "") String region, + @RequestParam(required = false, defaultValue = "") String keyword + ) { + applicationQueryService.validateSiteUserCanViewApplicants(siteUser); + ApplicationsResponse result = applicationQueryService.getApplicants(siteUser, region, keyword); + return ResponseEntity + .ok(result); + } + + @GetMapping("/competitors") + public ResponseEntity getApplicantsForUserCompetitors( + @AuthorizedUser SiteUser siteUser + ) { + applicationQueryService.validateSiteUserCanViewApplicants(siteUser); + ApplicationsResponse result = applicationQueryService.getApplicantsByUserApplications(siteUser); + return ResponseEntity + .ok(result); + } +} diff --git a/src/main/java/com/example/solidconnection/application/domain/Application.java b/src/main/java/com/example/solidconnection/application/domain/Application.java new file mode 100644 index 000000000..61dc5159e --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/domain/Application.java @@ -0,0 +1,143 @@ +package com.example.solidconnection.application.domain; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import static com.example.solidconnection.type.VerifyStatus.PENDING; + +@Getter +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@DynamicUpdate +@DynamicInsert +@Entity +public class Application { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private Gpa gpa; + + @Embedded + private LanguageTest languageTest; + + @Setter + @Column(columnDefinition = "varchar(50) not null default 'PENDING'") + @Enumerated(EnumType.STRING) + private VerifyStatus verifyStatus; + + @Column(length = 100) + private String nicknameForApply; + + @Column(columnDefinition = "int not null default 0") + private Integer updateCount; + + @Column(length = 50, nullable = false) + private String term; + + @Column + private boolean isDelete = false; + + @ManyToOne(fetch = FetchType.LAZY) + private UniversityInfoForApply firstChoiceUniversity; + + @ManyToOne(fetch = FetchType.LAZY) + private UniversityInfoForApply secondChoiceUniversity; + + @ManyToOne(fetch = FetchType.LAZY) + private UniversityInfoForApply thirdChoiceUniversity; + + @ManyToOne(fetch = FetchType.LAZY) + private SiteUser siteUser; + + public Application( + SiteUser siteUser, + Gpa gpa, + LanguageTest languageTest, + String term) { + this.siteUser = siteUser; + this.gpa = gpa; + this.languageTest = languageTest; + this.term = term; + this.updateCount = 0; + this.verifyStatus = PENDING; + } + + public Application( + SiteUser siteUser, + Gpa gpa, + LanguageTest languageTest, + String term, + Integer updateCount, + UniversityInfoForApply firstChoiceUniversity, + UniversityInfoForApply secondChoiceUniversity, + UniversityInfoForApply thirdChoiceUniversity, + String nicknameForApply) { + this.siteUser = siteUser; + this.gpa = gpa; + this.languageTest = languageTest; + this.term = term; + this.updateCount = updateCount; + this.firstChoiceUniversity = firstChoiceUniversity; + this.secondChoiceUniversity = secondChoiceUniversity; + this.thirdChoiceUniversity = thirdChoiceUniversity; + this.nicknameForApply = nicknameForApply; + this.verifyStatus = PENDING; + } + + public Application( + SiteUser siteUser, + Gpa gpa, + LanguageTest languageTest, + String term, + UniversityInfoForApply firstChoiceUniversity, + UniversityInfoForApply secondChoiceUniversity, + UniversityInfoForApply thirdChoiceUniversity, + String nicknameForApply) { + this.siteUser = siteUser; + this.gpa = gpa; + this.languageTest = languageTest; + this.term = term; + this.updateCount = 0; + this.firstChoiceUniversity = firstChoiceUniversity; + this.secondChoiceUniversity = secondChoiceUniversity; + this.thirdChoiceUniversity = thirdChoiceUniversity; + this.nicknameForApply = nicknameForApply; + this.verifyStatus = PENDING; + } + + public void setIsDeleteTrue() { + this.isDelete = true; + } + + public void updateUniversityChoice( + UniversityInfoForApply firstChoiceUniversity, + UniversityInfoForApply secondChoiceUniversity, + UniversityInfoForApply thirdChoiceUniversity, + String nicknameForApply) { + if (this.firstChoiceUniversity != null) { + this.updateCount++; + } + this.firstChoiceUniversity = firstChoiceUniversity; + this.secondChoiceUniversity = secondChoiceUniversity; + this.thirdChoiceUniversity = thirdChoiceUniversity; + this.nicknameForApply = nicknameForApply; + } +} diff --git a/src/main/java/com/example/solidconnection/application/domain/Gpa.java b/src/main/java/com/example/solidconnection/application/domain/Gpa.java new file mode 100644 index 000000000..85b12d047 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/domain/Gpa.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.application.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@Embeddable +@EqualsAndHashCode(of = {"gpa", "gpaCriteria", "gpaReportUrl"}) +public class Gpa { + + @Column(nullable = false, name = "gpa") + private Double gpa; + + @Column(nullable = false, name = "gpa_criteria") + private Double gpaCriteria; + + @Column(nullable = false, name = "gpa_report_url", length = 500) + private String gpaReportUrl; +} diff --git a/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java b/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java new file mode 100644 index 000000000..4295372d4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.application.domain; + +import com.example.solidconnection.type.LanguageTestType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@Embeddable +@EqualsAndHashCode(of = {"languageTestType", "languageTestScore", "languageTestReportUrl"}) +public class LanguageTest { + + @Column(nullable = false, name = "language_test_type", length = 10) + @Enumerated(EnumType.STRING) + private LanguageTestType languageTestType; + + @Column(nullable = false, name = "language_test_score") + private String languageTestScore; + + @Column(nullable = false, name = "language_test_report_url", length = 500) + private String languageTestReportUrl; +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java new file mode 100644 index 000000000..9835491b1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.application.dto; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.type.LanguageTestType; + +public record ApplicantResponse( + String nicknameForApply, + double gpa, + LanguageTestType testType, + String testScore, + boolean isMine) { + + public static ApplicantResponse of(Application application, boolean isMine) { + return new ApplicantResponse( + application.getNicknameForApply(), + application.getGpa().getGpa(), + application.getLanguageTest().getLanguageTestType(), + application.getLanguageTest().getLanguageTestScore(), + isMine + ); + } +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java new file mode 100644 index 000000000..4f353733b --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.application.dto; + +public record ApplicationSubmissionResponse( + boolean isSuccess) { +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java new file mode 100644 index 000000000..a3429c1ef --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.application.dto; + +import java.util.List; + +public record ApplicationsResponse( + List firstChoice, + List secondChoice, + List thirdChoice) { +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java b/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java new file mode 100644 index 000000000..7c4da1c99 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.application.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public record ApplyRequest( + + @NotNull(message = "gpa score id를 입력해주세요.") + Long gpaScoreId, + + @NotNull(message = "language test score id를 입력해주세요.") + Long languageTestScoreId, + + @Valid + UniversityChoiceRequest universityChoiceRequest +) { +} diff --git a/src/main/java/com/example/solidconnection/application/dto/UniversityApplicantsResponse.java b/src/main/java/com/example/solidconnection/application/dto/UniversityApplicantsResponse.java new file mode 100644 index 000000000..1d3415003 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/UniversityApplicantsResponse.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.application.dto; + +import com.example.solidconnection.university.domain.UniversityInfoForApply; + +import java.util.List; + +public record UniversityApplicantsResponse( + String koreanName, + int studentCapacity, + String region, + String country, + List applicants) { + + public static UniversityApplicantsResponse of(UniversityInfoForApply universityInfoForApply, List applicant) { + return new UniversityApplicantsResponse( + universityInfoForApply.getKoreanName(), + universityInfoForApply.getStudentCapacity(), + universityInfoForApply.getUniversity().getRegion().getKoreanName(), + universityInfoForApply.getUniversity().getCountry().getKoreanName(), + applicant); + } +} diff --git a/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java b/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java new file mode 100644 index 000000000..d219dbc2e --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.application.dto; + +import com.example.solidconnection.custom.validation.annotation.ValidUniversityChoice; + +@ValidUniversityChoice +public record UniversityChoiceRequest( + Long firstChoiceUniversityId, + Long secondChoiceUniversityId, + Long thirdChoiceUniversityId) { +} diff --git a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java new file mode 100644 index 000000000..1a06ec321 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java @@ -0,0 +1,44 @@ +package com.example.solidconnection.application.repository; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLICATION_NOT_FOUND; + +@Repository +public interface ApplicationRepository extends JpaRepository { + + boolean existsByNicknameForApply(String nicknameForApply); + + List findAllByFirstChoiceUniversityAndVerifyStatusAndTerm( + UniversityInfoForApply firstChoiceUniversity, VerifyStatus verifyStatus, String term); + + List findAllBySecondChoiceUniversityAndVerifyStatusAndTerm( + UniversityInfoForApply secondChoiceUniversity, VerifyStatus verifyStatus, String term); + + List findAllByThirdChoiceUniversityAndVerifyStatusAndTerm( + UniversityInfoForApply thirdChoiceUniversity, VerifyStatus verifyStatus, String term); + + @Query(""" + SELECT a FROM Application a + WHERE a.siteUser = :siteUser + AND a.term = :term + AND a.isDelete = false + """) + Optional findBySiteUserAndTerm(@Param("siteUser") SiteUser siteUser, @Param("term") String term); + + default Application getApplicationBySiteUserAndTerm(SiteUser siteUser, String term) { + return findBySiteUserAndTerm(siteUser, term) + .orElseThrow(() -> new CustomException(APPLICATION_NOT_FOUND)); + } +} diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java new file mode 100644 index 000000000..3208d24af --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java @@ -0,0 +1,134 @@ +package com.example.solidconnection.application.service; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.dto.ApplicantResponse; +import com.example.solidconnection.application.dto.ApplicationsResponse; +import com.example.solidconnection.application.dto.UniversityApplicantsResponse; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.custom.UniversityFilterRepositoryImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLICATION_NOT_APPROVED; + +@RequiredArgsConstructor +@Service +public class ApplicationQueryService { + + private final ApplicationRepository applicationRepository; + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final UniversityFilterRepositoryImpl universityFilterRepository; + + @Value("${university.term}") + public String term; + + /* + * 다른 지원자들의 성적을 조회한다. + * - 유저가 다른 지원자들을 볼 수 있는지 검증한다. + * - 지역과 키워드를 통해 대학을 필터링한다. + * - 지역은 영어 대문자로 받는다 e.g. ASIA + * - 1지망, 2지망 지원자들을 조회한다. + * */ + @Transactional(readOnly = true) + // todo: 임시로 단일 키로 캐시 적용. 추후 캐싱 전략 재검토 필요. + @ThunderingHerdCaching(key = "applications:all", cacheManager = "customCacheManager", ttlSec = 86400) + public ApplicationsResponse getApplicants(SiteUser siteUser, String regionCode, String keyword) { + // 국가와 키워드와 지역을 통해 대학을 필터링한다. + List universities + = universityFilterRepository.findByRegionCodeAndKeywords(regionCode, List.of(keyword)); + + // 1지망, 2지망, 3지망 지원자들을 조회한다. + List firstChoiceApplicants = getFirstChoiceApplicants(universities, siteUser, term); + List secondChoiceApplicants = getSecondChoiceApplicants(universities, siteUser, term); + List thirdChoiceApplicants = getThirdChoiceApplicants(universities, siteUser, term); + return new ApplicationsResponse(firstChoiceApplicants, secondChoiceApplicants, thirdChoiceApplicants); + } + + @Transactional(readOnly = true) + public ApplicationsResponse getApplicantsByUserApplications(SiteUser siteUser) { + Application userLatestApplication = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term); + List userAppliedUniversities = Arrays.asList( + Optional.ofNullable(userLatestApplication.getFirstChoiceUniversity()) + .map(UniversityInfoForApply::getUniversity) + .orElse(null), + Optional.ofNullable(userLatestApplication.getSecondChoiceUniversity()) + .map(UniversityInfoForApply::getUniversity) + .orElse(null), + Optional.ofNullable(userLatestApplication.getThirdChoiceUniversity()) + .map(UniversityInfoForApply::getUniversity) + .orElse(null) + ).stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + List firstChoiceApplicants = getFirstChoiceApplicants(userAppliedUniversities, siteUser, term); + List secondChoiceApplicants = getSecondChoiceApplicants(userAppliedUniversities, siteUser, term); + List thirdChoiceApplicants = getThirdChoiceApplicants(userAppliedUniversities, siteUser, term); + return new ApplicationsResponse(firstChoiceApplicants, secondChoiceApplicants, thirdChoiceApplicants); + } + + // 학기별로 상태가 관리된다. + // 금학기에 지원이력이 있는 사용자만 지원정보를 확인할 수 있도록 한다. + @Transactional(readOnly = true) + public void validateSiteUserCanViewApplicants(SiteUser siteUser) { + VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term).getVerifyStatus(); + if (verifyStatus != VerifyStatus.APPROVED) { + throw new CustomException(APPLICATION_NOT_APPROVED); + } + } + + private List getFirstChoiceApplicants(List universities, SiteUser siteUser, String term) { + return getApplicantsByChoice( + universities, + siteUser, + uia -> applicationRepository.findAllByFirstChoiceUniversityAndVerifyStatusAndTerm(uia, VerifyStatus.APPROVED, term) + ); + } + + private List getSecondChoiceApplicants(List universities, SiteUser siteUser, String term) { + return getApplicantsByChoice( + universities, + siteUser, + uia -> applicationRepository.findAllBySecondChoiceUniversityAndVerifyStatusAndTerm(uia, VerifyStatus.APPROVED, term) + ); + } + + private List getThirdChoiceApplicants(List universities, SiteUser siteUser, String term) { + return getApplicantsByChoice( + universities, + siteUser, + uia -> applicationRepository.findAllByThirdChoiceUniversityAndVerifyStatusAndTerm(uia, VerifyStatus.APPROVED, term) + ); + } + + private List getApplicantsByChoice( + List searchedUniversities, + SiteUser siteUser, + Function> findApplicationsByChoice) { + return universityInfoForApplyRepository.findByUniversitiesAndTerm(searchedUniversities, term).stream() + .map(universityInfoForApply -> UniversityApplicantsResponse.of( + universityInfoForApply, + findApplicationsByChoice.apply(universityInfoForApply).stream() + .map(ap -> ApplicantResponse.of( + ap, + Objects.equals(siteUser.getId(), ap.getSiteUser().getId()))) + .toList())) + .toList(); + } +} diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java new file mode 100644 index 000000000..ea05c3c0c --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -0,0 +1,121 @@ +package com.example.solidconnection.application.service; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.dto.ApplyRequest; +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.cache.annotation.DefaultCacheOut; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_GPA_SCORE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; + +@RequiredArgsConstructor +@Service +public class ApplicationSubmissionService { + + public static final int APPLICATION_UPDATE_COUNT_LIMIT = 3; + + private final ApplicationRepository applicationRepository; + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final GpaScoreRepository gpaScoreRepository; + private final LanguageTestScoreRepository languageTestScoreRepository; + + @Value("${university.term}") + private String term; + + // 학점 및 어학성적이 모두 유효한 경우에만 지원서 등록이 가능하다. + // 기존에 있던 status field 우선 APRROVED로 입력시킨다. + @Transactional + // todo: 임시로 새로운 신청 생성 시 기존 캐싱 데이터를 삭제한다. 추후 수정 필요 + @DefaultCacheOut( + key = {"applications:all"}, + cacheManager = "customCacheManager" + ) + public boolean apply(SiteUser siteUser, ApplyRequest applyRequest) { + UniversityChoiceRequest universityChoiceRequest = applyRequest.universityChoiceRequest(); + + Long gpaScoreId = applyRequest.gpaScoreId(); + Long languageTestScoreId = applyRequest.languageTestScoreId(); + GpaScore gpaScore = getValidGpaScore(siteUser, gpaScoreId); + LanguageTestScore languageTestScore = getValidLanguageTestScore(siteUser, languageTestScoreId); + + Optional application = applicationRepository.findBySiteUserAndTerm(siteUser, term); + + UniversityInfoForApply firstChoiceUniversity = universityInfoForApplyRepository + .getUniversityInfoForApplyByIdAndTerm(universityChoiceRequest.firstChoiceUniversityId(), term); + UniversityInfoForApply secondChoiceUniversity = Optional.ofNullable(universityChoiceRequest.secondChoiceUniversityId()) + .map(id -> universityInfoForApplyRepository.getUniversityInfoForApplyByIdAndTerm(id, term)) + .orElse(null); + UniversityInfoForApply thirdChoiceUniversity = Optional.ofNullable(universityChoiceRequest.thirdChoiceUniversityId()) + .map(id -> universityInfoForApplyRepository.getUniversityInfoForApplyByIdAndTerm(id, term)) + .orElse(null); + + if (application.isEmpty()) { + Application newApplication = new Application(siteUser, gpaScore.getGpa(), languageTestScore.getLanguageTest(), + term, firstChoiceUniversity, secondChoiceUniversity, thirdChoiceUniversity, getRandomNickname()); + newApplication.setVerifyStatus(VerifyStatus.APPROVED); + applicationRepository.save(newApplication); + } else { + Application before = application.get(); + validateUpdateLimitNotExceed(before); + before.setIsDeleteTrue(); // 기존 이력 soft delete 수행한다. + + Application newApplication = new Application(siteUser, gpaScore.getGpa(), languageTestScore.getLanguageTest(), + term, before.getUpdateCount() + 1, firstChoiceUniversity, secondChoiceUniversity, thirdChoiceUniversity, getRandomNickname()); + newApplication.setVerifyStatus(VerifyStatus.APPROVED); + applicationRepository.save(newApplication); + } + return true; + } + + private GpaScore getValidGpaScore(SiteUser siteUser, Long gpaScoreId) { + GpaScore gpaScore = gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId) + .orElseThrow(() -> new CustomException(INVALID_GPA_SCORE)); + if (gpaScore.getVerifyStatus() != VerifyStatus.APPROVED) { + throw new CustomException(INVALID_GPA_SCORE_STATUS); + } + return gpaScore; + } + + private LanguageTestScore getValidLanguageTestScore(SiteUser siteUser, Long languageTestScoreId) { + LanguageTestScore languageTestScore = languageTestScoreRepository + .findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId) + .orElseThrow(() -> new CustomException(INVALID_LANGUAGE_TEST_SCORE)); + if (languageTestScore.getVerifyStatus() != VerifyStatus.APPROVED) { + throw new CustomException(INVALID_LANGUAGE_TEST_SCORE_STATUS); + } + return languageTestScore; + } + + private String getRandomNickname() { + String randomNickname = NicknameCreator.createRandomNickname(); + while (applicationRepository.existsByNicknameForApply(randomNickname)) { + randomNickname = NicknameCreator.createRandomNickname(); + } + return randomNickname; + } + + private void validateUpdateLimitNotExceed(Application application) { + if (application.getUpdateCount() >= APPLICATION_UPDATE_COUNT_LIMIT) { + throw new CustomException(APPLY_UPDATE_LIMIT_EXCEED); + } + } +} diff --git a/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java b/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java new file mode 100644 index 000000000..d9243ce39 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.application.service; + +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Random; +import java.util.Set; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +class NicknameCreator { + + public static final List ADJECTIVES = List.copyOf(Set.of( + "기쁜", "행복한", "즐거운", "밝은", "따뜻한", "시원한", "고고한", "예쁜", "신선한", "풍부한", "깨끗한", + "귀한", "눈부신", "멋진", "고귀한", "화려한", "상큼한", "활기찬", "유쾌한", "똘똘한", "친절한", "좋은", + "영리한", "용감한", "정직한", "성실한", "강인한", "귀여운", "순수한", "희망찬", "발랄한", "나른한", "후한", "빛나는", + "따스한", "안락한", "편안한", "성공한", "재미난", "청량한", "찬란한", "소중한", "특별한", "단순한", "반가운", "그리운") + ); + public static final List NOUNS = List.copyOf(Set.of( + "청춘", "토끼", "기사", "곰", "사슴", "여우", "팬더", "이슬", "새싹", "햇빛", "나비", "별", "달", "구름", + "사탕", "젤리", "마법", "풍선", "캔디", "초코", "인형", "쿠키", "요정", "장미", "마녀", "보물", "꽃", "보석", + "달빛", "오리", "날개", "여행", "편지", "불꽃", "파도", "별빛", "구슬", "노래", "음표", "선율", "미소", "가방", + "거울", "씨앗", "열매", "바다", "약속", "구두", "공기", "등불", "촛불", "진주", "꿀벌", "예감", "바람", + "오전", "오후", "아침", "점심", "저녁") + ); + + private static final Random RANDOM = new Random(); + + public static String createRandomNickname() { + String randomAdjective = ADJECTIVES.get(RANDOM.nextInt(ADJECTIVES.size())); + String randomNoun = NOUNS.get(RANDOM.nextInt(NOUNS.size())); + return randomAdjective + " " + randomNoun; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java new file mode 100644 index 000000000..aef1309af --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -0,0 +1,83 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.auth.dto.oauth.AppleTokenDto; +import com.example.solidconnection.auth.dto.oauth.AppleUserInfoDto; +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.security.PublicKey; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; + +/* + * 애플 인증을 위한 OAuth2 클라이언트 + * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClient { + + private final RestTemplate restTemplate; + private final AppleOAuthClientProperties properties; + private final AppleOAuthClientSecretProvider clientSecretProvider; + private final ApplePublicKeyProvider publicKeyProvider; + + public AppleUserInfoDto processOAuth(String code) { + String idToken = requestIdToken(code); + PublicKey applePublicKey = publicKeyProvider.getApplePublicKey(idToken); + return new AppleUserInfoDto(parseEmailFromToken(applePublicKey, idToken)); + } + + public String requestIdToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap formData = buildFormData(code); + + try { + ResponseEntity response = restTemplate.exchange( + properties.tokenUrl(), + HttpMethod.POST, + new HttpEntity<>(formData, headers), + AppleTokenDto.class + ); + return Objects.requireNonNull(response.getBody()).idToken(); + } catch (Exception e) { + throw new CustomException(APPLE_AUTHORIZATION_FAILED, e.getMessage()); + } + } + + private MultiValueMap buildFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", properties.clientId()); + formData.add("client_secret", clientSecretProvider.generateClientSecret()); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", properties.redirectUrl()); + return formData; + } + + private String parseEmailFromToken(PublicKey applePublicKey, String idToken) { + try { + return Jwts.parser() + .setSigningKey(applePublicKey) + .parseClaimsJws(idToken) + .getBody() + .get("email", String.class); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java new file mode 100644 index 000000000..2de0b7291 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.apache.tomcat.util.codec.binary.Base64; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; + +/* + * 애플 OAuth 에 필요하 클라이언트 시크릿은 매번 동적으로 생성해야 한다. + * 클라이언트 시크릿은 애플 개발자 계정에서 발급받은 개인키(*.p8)를 사용하여 JWT 를 생성한다. + * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClientSecretProvider { + + private static final String KEY_ID_HEADER = "kid"; + private static final long TOKEN_DURATION = 1000 * 60 * 10; // 10min + private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8"; + + private final AppleOAuthClientProperties appleOAuthClientProperties; + private PrivateKey privateKey; + + @PostConstruct + private void initPrivateKey() { + privateKey = readPrivateKey(); + } + + public String generateClientSecret() { + Date now = new Date(); + Date expiration = new Date(now.getTime() + TOKEN_DURATION); + + return Jwts.builder() + .setHeaderParam("alg", "ES256") + .setHeaderParam(KEY_ID_HEADER, appleOAuthClientProperties.keyId()) + .setSubject(appleOAuthClientProperties.clientId()) + .setIssuer(appleOAuthClientProperties.teamId()) + .setAudience(appleOAuthClientProperties.clientSecretAudienceUrl()) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.ES256, privateKey) + .compact(); + } + + private PrivateKey readPrivateKey() { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(SECRET_KEY_PATH); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + + String secretKey = reader.lines().collect(Collectors.joining("\n")); + byte[] encoded = Base64.decodeBase64(secretKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new CustomException(FAILED_TO_READ_APPLE_PRIVATE_KEY); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java new file mode 100644 index 000000000..1cc708cc7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java @@ -0,0 +1,94 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; +import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; + +/* +* idToken 검증을 위해서 애플의 공개키를 가져온다. +* - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다. +* - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다. +* - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다. +* https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature +* */ +@Component +@RequiredArgsConstructor +public class ApplePublicKeyProvider { + + private final AppleOAuthClientProperties properties; + private final RestTemplate restTemplate; + + private final Map applePublicKeyCache = new ConcurrentHashMap<>(); + + public PublicKey getApplePublicKey(String idToken) { + try { + String kid = getKeyIdFromTokenHeader(idToken); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } + + fetchApplePublicKeys(); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } else { + throw new CustomException(APPLE_PUBLIC_KEY_NOT_FOUND); + } + } catch (ExpiredJwtException e) { + throw new CustomException(APPLE_ID_TOKEN_EXPIRED); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } + + /* + * idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다 + * 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다. + * */ + private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException { + String[] jwtParts = idToken.split("\\."); + if (jwtParts.length < 2) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0]), StandardCharsets.UTF_8); + return new ObjectMapper().readTree(headerJson).get("kid").asText(); + } + + private void fetchApplePublicKeys() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + ResponseEntity response = restTemplate.getForEntity(properties.publicKeyUrl(), String.class); + JsonNode jsonNode = objectMapper.readTree(response.getBody()).get("keys"); + + applePublicKeyCache.clear(); + for (JsonNode key : jsonNode) { + applePublicKeyCache.put(key.get("kid").asText(), generatePublicKey(key)); + } + } + + private PublicKey generatePublicKey(JsonNode key) throws Exception { + BigInteger modulus = new BigInteger(1, decodeBase64URLSafe(key.get("n").asText())); + BigInteger exponent = new BigInteger(1, decodeBase64URLSafe(key.get("e").asText())); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java new file mode 100644 index 000000000..5d625cb7c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -0,0 +1,87 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.auth.dto.oauth.KakaoTokenDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; +import com.example.solidconnection.config.client.KakaoOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_OR_EXPIRED_KAKAO_AUTH_CODE; +import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH; +import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_USER_INFO_FAIL; + +/* + * 카카오 인증을 위한 OAuth2 클라이언트 + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + * */ +@Component +@RequiredArgsConstructor +public class KakaoOAuthClient { + + private final RestTemplate restTemplate; + private final KakaoOAuthClientProperties kakaoOAuthClientProperties; + + public KakaoUserInfoDto getUserInfo(String code) { + String kakaoAccessToken = getKakaoAccessToken(code); + return getKakaoUserInfo(kakaoAccessToken); + } + + private String getKakaoAccessToken(String code) { + try { + ResponseEntity response = restTemplate.exchange( + buildTokenUri(code), + HttpMethod.POST, + null, + KakaoTokenDto.class + ); + return Objects.requireNonNull(response.getBody()).accessToken(); + } catch (Exception e) { + if (e.getMessage().contains("KOE303")) { + throw new CustomException(KAKAO_REDIRECT_URI_MISMATCH); + } + if (e.getMessage().contains("KOE320")) { + throw new CustomException(INVALID_OR_EXPIRED_KAKAO_AUTH_CODE); + } + throw new CustomException(INVALID_OR_EXPIRED_KAKAO_AUTH_CODE); + } + } + + private String buildTokenUri(String code) { + return UriComponentsBuilder.fromHttpUrl(kakaoOAuthClientProperties.tokenUrl()) + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", kakaoOAuthClientProperties.clientId()) + .queryParam("redirect_uri", kakaoOAuthClientProperties.redirectUrl()) + .queryParam("code", code) + .toUriString(); + } + + private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + ResponseEntity response = restTemplate.exchange( + kakaoOAuthClientProperties.userInfoUrl(), + HttpMethod.GET, + new HttpEntity<>(headers), + KakaoUserInfoDto.class + ); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + return response.getBody(); + } else { + throw new CustomException(KAKAO_USER_INFO_FAIL); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java new file mode 100644 index 000000000..9c84e8d22 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -0,0 +1,126 @@ +package com.example.solidconnection.auth.controller; + +import com.example.solidconnection.auth.dto.EmailSignInRequest; +import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest; +import com.example.solidconnection.auth.dto.EmailSignUpTokenResponse; +import com.example.solidconnection.auth.dto.ReissueResponse; +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.service.AuthService; +import com.example.solidconnection.auth.service.CommonSignUpTokenProvider; +import com.example.solidconnection.auth.service.EmailSignInService; +import com.example.solidconnection.auth.service.EmailSignUpService; +import com.example.solidconnection.auth.service.EmailSignUpTokenProvider; +import com.example.solidconnection.auth.service.oauth.AppleOAuthService; +import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; +import com.example.solidconnection.auth.service.oauth.OAuthSignUpService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/auth") +@RestController +public class AuthController { + + private final AuthService authService; + private final OAuthSignUpService oAuthSignUpService; + private final AppleOAuthService appleOAuthService; + private final KakaoOAuthService kakaoOAuthService; + private final EmailSignInService emailSignInService; + private final EmailSignUpService emailSignUpService; + private final EmailSignUpTokenProvider emailSignUpTokenProvider; + private final CommonSignUpTokenProvider commonSignUpTokenProvider; + + @PostMapping("/apple") + public ResponseEntity processAppleOAuth( + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + ) { + OAuthResponse oAuthResponse = appleOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); + } + + @PostMapping("/kakao") + public ResponseEntity processKakaoOAuth( + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + ) { + OAuthResponse oAuthResponse = kakaoOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); + } + + @PostMapping("/email/sign-in") + public ResponseEntity signInWithEmail( + @Valid @RequestBody EmailSignInRequest signInRequest + ) { + SignInResponse signInResponse = emailSignInService.signIn(signInRequest); + return ResponseEntity.ok(signInResponse); + } + + /* 이메일 회원가입 시 signUpToken 을 발급받기 위한 api */ + @PostMapping("/email/sign-up") + public ResponseEntity signUpWithEmail( + @Valid @RequestBody EmailSignUpTokenRequest signUpRequest + ) { + emailSignUpService.validateUniqueEmail(signUpRequest.email()); + String signUpToken = emailSignUpTokenProvider.generateAndSaveSignUpToken(signUpRequest); + return ResponseEntity.ok(new EmailSignUpTokenResponse(signUpToken)); + } + + @PostMapping("/sign-up") + public ResponseEntity signUp( + @Valid @RequestBody SignUpRequest signUpRequest + ) { + AuthType authType = commonSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + if (AuthType.isEmail(authType)) { + SignInResponse signInResponse = emailSignUpService.signUp(signUpRequest); + return ResponseEntity.ok(signInResponse); + } + SignInResponse signInResponse = oAuthSignUpService.signUp(signUpRequest); + return ResponseEntity.ok(signInResponse); + } + + @PostMapping("/sign-out") + public ResponseEntity signOut( + Authentication authentication + ) { + String token = authentication.getCredentials().toString(); + if (token == null) { + throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다."); + } + authService.signOut(token); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/quit") + public ResponseEntity quit( + @AuthorizedUser SiteUser siteUser + ) { + authService.quit(siteUser); + return ResponseEntity.ok().build(); + } + + @PostMapping("/reissue") + public ResponseEntity reissueToken( + Authentication authentication + ) { + String token = authentication.getCredentials().toString(); + if (token == null) { + throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다."); + } + ReissueResponse reissueResponse = authService.reissue(token); + return ResponseEntity.ok(reissueResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java new file mode 100644 index 000000000..caf1c7a9d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.auth.domain; + +import lombok.Getter; + +@Getter +public enum TokenType { + + ACCESS("ACCESS:", 1000 * 60 * 60), // 1hour + REFRESH("REFRESH:", 1000 * 60 * 60 * 24 * 7), // 7days + BLACKLIST("BLACKLIST:", ACCESS.expireTime), + SIGN_UP("SIGN_UP:", 1000 * 60 * 10), // 10min + ; + + private final String prefix; + private final int expireTime; + + TokenType(String prefix, int expireTime) { + this.prefix = prefix; + this.expireTime = expireTime; + } + + public String addPrefix(String string) { + return prefix + string; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java new file mode 100644 index 000000000..306a8185a --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record EmailSignInRequest( + + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java new file mode 100644 index 000000000..92073b434 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailSignUpTokenRequest( + + @Email(message = "이메일을 입력해주세요.") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java new file mode 100644 index 000000000..c8e983d0c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.auth.dto; + +public record EmailSignUpTokenResponse( + String signUpToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java b/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java new file mode 100644 index 000000000..48b55e6cb --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.auth.dto; + +public record ReissueResponse( + String accessToken) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java new file mode 100644 index 000000000..a4ae442e2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.dto; + +public record SignInResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java new file mode 100644 index 000000000..43f8e6caf --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.auth.dto; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +public record SignUpRequest( + String signUpToken, + List interestedRegions, + List interestedCountries, + PreparationStatus preparationStatus, + String profileImageUrl, + Gender gender, + + @NotBlank(message = "닉네임을 입력해주세요.") + String nickname, + + @JsonFormat(pattern = "yyyy-MM-dd") + String birth) { + + public SiteUser toOAuthSiteUser(String email, AuthType authType) { + return new SiteUser( + email, + this.nickname, + this.profileImageUrl, + this.birth, + this.preparationStatus, + Role.MENTEE, + this.gender, + authType + ); + } + + public SiteUser toEmailSiteUser(String email, String encodedPassword) { + return new SiteUser( + email, + this.nickname, + this.profileImageUrl, + this.birth, + this.preparationStatus, + Role.MENTEE, + this.gender, + AuthType.EMAIL, + encodedPassword + ); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java new file mode 100644 index 000000000..6772cb2c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppleTokenDto( + @JsonProperty("id_token") String idToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java new file mode 100644 index 000000000..5c4363e51 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.auth.dto.oauth; + +/* +* 애플로부터 사용자의 정보를 받아올 때 사용한다. +* 카카오와 달리 애플은 더 엄격하게 사용자 정보를 관리하여, 이름이나 프로필 이미지 url 을 제공하지 않는다. +* 따라서 닉네임, 프로필 정보는 null 을 반환한다. +* */ +public record AppleUserInfoDto(String email) implements OAuthUserInfoDto { + + @Override + public String getEmail() { + return email; + } + + @Override + public String getProfileImageUrl() { + return null; + } + + @Override + public String getNickname() { + return null; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java new file mode 100644 index 000000000..6d4ccd10c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoTokenDto( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java new file mode 100644 index 000000000..fbd975b50 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.auth.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoUserInfoDto( + @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) implements OAuthUserInfoDto { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record KakaoAccountDto( + @JsonProperty("profile") KakaoProfileDto profile, + String email) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record KakaoProfileDto( + @JsonProperty("profile_image_url") String profileImageUrl, + String nickname) { + + } + } + + @Override + public String getEmail() { + return kakaoAccountDto.email; + } + + @Override + public String getProfileImageUrl() { + return kakaoAccountDto.profile.profileImageUrl; + } + + @Override + public String getNickname() { + return kakaoAccountDto.profile.nickname; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java new file mode 100644 index 000000000..abbdb7802 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.auth.dto.oauth; + +import jakarta.validation.constraints.NotBlank; + +public record OAuthCodeRequest( + + @NotBlank(message = "인증 코드를 입력해주세요.") + String code) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java new file mode 100644 index 000000000..ddbe121f7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java @@ -0,0 +1,4 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java new file mode 100644 index 000000000..8ad429876 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record OAuthSignInResponse( + boolean isRegistered, + String accessToken, + String refreshToken) implements OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java new file mode 100644 index 000000000..ed794851b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthUserInfoDto { + + String getEmail(); + + String getProfileImageUrl(); + + String getNickname(); +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java new file mode 100644 index 000000000..5a6c60c57 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record SignUpPrepareResponse( + boolean isRegistered, + String nickname, + String email, + String profileImageUrl, + String signUpToken) implements OAuthResponse { + + public static SignUpPrepareResponse of(OAuthUserInfoDto oAuthUserInfoDto, String signUpToken) { + return new SignUpPrepareResponse( + false, + oAuthUserInfoDto.getNickname(), + oAuthUserInfoDto.getEmail(), + oAuthUserInfoDto.getProfileImageUrl(), + signUpToken + ); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthService.java b/src/main/java/com/example/solidconnection/auth/service/AuthService.java new file mode 100644 index 000000000..04bcadde7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/AuthService.java @@ -0,0 +1,56 @@ +package com.example.solidconnection.auth.service; + + +import com.example.solidconnection.auth.dto.ReissueResponse; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; + +@RequiredArgsConstructor +@Service +public class AuthService { + + private final AuthTokenProvider authTokenProvider; + + /* + * 로그아웃 한다. + * - 엑세스 토큰을 블랙리스트에 추가한다. + * */ + public void signOut(String accessToken) { + authTokenProvider.generateAndSaveBlackListToken(accessToken); + } + + /* + * 탈퇴한다. + * - 탈퇴한 시점의 다음날을 탈퇴일로 잡는다. + * - e.g. 2024-01-01 18:00 탈퇴 시, 2024-01-02 00:00 가 탈퇴일이 된다. + * */ + @Transactional + public void quit(SiteUser siteUser) { + LocalDate tomorrow = LocalDate.now().plusDays(1); + siteUser.setQuitedAt(tomorrow); + } + + /* + * 액세스 토큰을 재발급한다. + * - 리프레시 토큰이 만료되었거나, 존재하지 않는다면 예외 응답을 반환한다. + * - 리프레시 토큰이 존재한다면, 액세스 토큰을 재발급한다. + * */ + public ReissueResponse reissue(String subject) { + // 리프레시 토큰 만료 확인 + Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); + if (optionalRefreshToken.isEmpty()) { + throw new CustomException(REFRESH_TOKEN_EXPIRED); + } + // 액세스 토큰 재발급 + String newAccessToken = authTokenProvider.generateAccessToken(subject); + return new ReissueResponse(newAccessToken); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java new file mode 100644 index 000000000..da040a8d5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; + +@Component +public class AuthTokenProvider extends TokenProvider { + + public AuthTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + super(jwtProperties, redisTemplate); + } + + public String generateAccessToken(SiteUser siteUser) { + String subject = siteUser.getId().toString(); + return generateToken(subject, TokenType.ACCESS); + } + + public String generateAccessToken(String subject) { + return generateToken(subject, TokenType.ACCESS); + } + + public String generateAndSaveRefreshToken(SiteUser siteUser) { + String subject = siteUser.getId().toString(); + String refreshToken = generateToken(subject, TokenType.REFRESH); + return saveToken(refreshToken, TokenType.REFRESH); + } + + public String generateAndSaveBlackListToken(String accessToken) { + String blackListToken = generateToken(accessToken, TokenType.BLACKLIST); + return saveToken(blackListToken, TokenType.BLACKLIST); + } + + public Optional findRefreshToken(String subject) { + String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); + return Optional.ofNullable(redisTemplate.opsForValue().get(refreshTokenKey)); + } + + public Optional findBlackListToken(String subject) { + String blackListTokenKey = TokenType.BLACKLIST.addPrefix(subject); + return Optional.ofNullable(redisTemplate.opsForValue().get(blackListTokenKey)); + } + + public String getEmail(String token) { + return parseSubjectIgnoringExpiration(token, jwtProperties.secret()); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java new file mode 100644 index 000000000..3d0eda53b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.util.JwtUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.auth.service.EmailSignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; + +@Component +@RequiredArgsConstructor +public class CommonSignUpTokenProvider { + + private final JwtProperties jwtProperties; + + public AuthType parseAuthType(String signUpToken) { + try { + String authTypeStr = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()).get(AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(authTypeStr); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java new file mode 100644 index 000000000..3e26309a5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.EmailSignInRequest; +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; + +/* + * 보안을 위해 이메일과 비밀번호 중 무엇이 틀렸는지 구체적으로 응답하지 않는다. + * */ +@Service +@RequiredArgsConstructor +public class EmailSignInService { + + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + private final PasswordEncoder passwordEncoder; + + public SignInResponse signIn(EmailSignInRequest signInRequest) { + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(signInRequest.email(), AuthType.EMAIL); + if (optionalSiteUser.isPresent()) { + SiteUser siteUser = optionalSiteUser.get(); + validatePassword(signInRequest.password(), siteUser.getPassword()); + return signInService.signIn(siteUser); + } + throw new CustomException(USER_NOT_FOUND, "이메일과 비밀번호를 확인해주세요."); + } + + private void validatePassword(String rawPassword, String encodedPassword) { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new CustomException(USER_NOT_FOUND, "이메일과 비밀번호를 확인해주세요."); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java new file mode 100644 index 000000000..37f6681ea --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; + +@Service +public class EmailSignUpService extends SignUpService { + + private final EmailSignUpTokenProvider emailSignUpTokenProvider; + + public EmailSignUpService(SignInService signInService, SiteUserRepository siteUserRepository, + RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, + CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository, + EmailSignUpTokenProvider emailSignUpTokenProvider) { + super(signInService, siteUserRepository, regionRepository, interestedRegionRepository, countryRepository, interestedCountyRepository); + this.emailSignUpTokenProvider = emailSignUpTokenProvider; + } + + public void validateUniqueEmail(String email) { + if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + + @Override + protected void validateSignUpToken(SignUpRequest signUpRequest) { + emailSignUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); + } + + @Override + protected void validateUserNotDuplicated(SignUpRequest signUpRequest) { + String email = emailSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + + @Override + protected SiteUser createSiteUser(SignUpRequest signUpRequest) { + String email = emailSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + String encodedPassword = emailSignUpTokenProvider.parseEncodedPassword(signUpRequest.signUpToken()); + return signUpRequest.toEmailSiteUser(email, encodedPassword); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java new file mode 100644 index 000000000..1c27a87bd --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java @@ -0,0 +1,92 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static com.example.solidconnection.util.JwtUtils.parseClaims; +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +public class EmailSignUpTokenProvider extends TokenProvider { + + static final String PASSWORD_CLAIM_KEY = "password"; + static final String AUTH_TYPE_CLAIM_KEY = "authType"; + + private final PasswordEncoder passwordEncoder; + + public EmailSignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate, + PasswordEncoder passwordEncoder) { + super(jwtProperties, redisTemplate); + this.passwordEncoder = passwordEncoder; + } + + public String generateAndSaveSignUpToken(EmailSignUpTokenRequest request) { + String email = request.email(); + String password = request.password(); + String encodedPassword = passwordEncoder.encode(password); + Map emailSignUpClaims = new HashMap<>(Map.of( + PASSWORD_CLAIM_KEY, encodedPassword, + AUTH_TYPE_CLAIM_KEY, AuthType.EMAIL + )); + Claims claims = Jwts.claims(emailSignUpClaims).setSubject(email); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); + + String signUpToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + return saveToken(signUpToken, TokenType.SIGN_UP); + } + + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + String email = parseEmail(token); + validateIssuedByServer(email); + } + + private void validateFormatAndExpiration(String token) { + try { + Claims claims = parseClaims(token, jwtProperties.secret()); + Objects.requireNonNull(claims.getSubject()); + String encodedPassword = claims.get(PASSWORD_CLAIM_KEY, String.class); + Objects.requireNonNull(encodedPassword); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + if (redisTemplate.opsForValue().get(key) == null) { + throw new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + } + } + + public String parseEmail(String token) { + return parseSubject(token, jwtProperties.secret()); + } + + public String parseEncodedPassword(String token) { + Claims claims = parseClaims(token, jwtProperties.secret()); + return claims.get(PASSWORD_CLAIM_KEY, String.class); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/SignInService.java new file mode 100644 index 000000000..820d2e573 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/SignInService.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SignInService { + + private final AuthTokenProvider authTokenProvider; + + @Transactional + public SignInResponse signIn(SiteUser siteUser) { + resetQuitedAt(siteUser); + String accessToken = authTokenProvider.generateAccessToken(siteUser); + String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + return new SignInResponse(accessToken, refreshToken); + } + + private void resetQuitedAt(SiteUser siteUser) { + if (siteUser.getQuitedAt() == null) { + return; + } + siteUser.setQuitedAt(null); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java new file mode 100644 index 000000000..319083658 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java @@ -0,0 +1,90 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; + +/* + * 우리 서버에서 인증되었음을 확인하기 위한 signUpToken 을 검증한다. + * - 사용자 정보를 DB에 저장한다. + * - 관심 국가와 지역을 DB에 저장한다. + * - 관심 국가와 지역은 site_user_id를 참조하므로, 사용자 저장 후 저장한다. + * - 바로 로그인하도록 액세스 토큰과 리프레시 토큰을 발급한다. + * */ +public abstract class SignUpService { + + protected final SignInService signInService; + protected final SiteUserRepository siteUserRepository; + protected final RegionRepository regionRepository; + protected final InterestedRegionRepository interestedRegionRepository; + protected final CountryRepository countryRepository; + protected final InterestedCountyRepository interestedCountyRepository; + + protected SignUpService(SignInService signInService, SiteUserRepository siteUserRepository, + RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, + CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository) { + this.signInService = signInService; + this.siteUserRepository = siteUserRepository; + this.regionRepository = regionRepository; + this.interestedRegionRepository = interestedRegionRepository; + this.countryRepository = countryRepository; + this.interestedCountyRepository = interestedCountyRepository; + } + + @Transactional + public SignInResponse signUp(SignUpRequest signUpRequest) { + // 검증 + validateSignUpToken(signUpRequest); + validateUserNotDuplicated(signUpRequest); + validateNicknameDuplicated(signUpRequest.nickname()); + + // 사용자 저장 + SiteUser siteUser = siteUserRepository.save(createSiteUser(signUpRequest)); + + // 관심 지역, 국가 저장 + saveInterestedRegion(signUpRequest, siteUser); + saveInterestedCountry(signUpRequest, siteUser); + + // 로그인 + return signInService.signIn(siteUser); + } + + private void validateNicknameDuplicated(String nickname) { + if (siteUserRepository.existsByNickname(nickname)) { + throw new CustomException(NICKNAME_ALREADY_EXISTED); + } + } + + private void saveInterestedRegion(SignUpRequest signUpRequest, SiteUser savedSiteUser) { + List interestedRegionNames = signUpRequest.interestedRegions(); + List interestedRegions = regionRepository.findByKoreanNames(interestedRegionNames).stream() + .map(region -> new InterestedRegion(savedSiteUser, region)) + .toList(); + interestedRegionRepository.saveAll(interestedRegions); + } + + private void saveInterestedCountry(SignUpRequest signUpRequest, SiteUser savedSiteUser) { + List interestedCountryNames = signUpRequest.interestedCountries(); + List interestedCountries = countryRepository.findByKoreanNames(interestedCountryNames).stream() + .map(country -> new InterestedCountry(savedSiteUser, country)) + .toList(); + interestedCountyRepository.saveAll(interestedCountries); + } + + protected abstract void validateSignUpToken(SignUpRequest signUpRequest); + protected abstract void validateUserNotDuplicated(SignUpRequest signUpRequest); + protected abstract SiteUser createSiteUser(SignUpRequest signUpRequest); +} diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java new file mode 100644 index 000000000..f5f638ab3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +public abstract class TokenProvider { + + protected final JwtProperties jwtProperties; + protected final RedisTemplate redisTemplate; + + public TokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + this.jwtProperties = jwtProperties; + this.redisTemplate = redisTemplate; + } + + protected final String generateToken(String string, TokenType tokenType) { + Claims claims = Jwts.claims().setSubject(string); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + tokenType.getExpireTime()); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + } + + protected final String saveToken(String token, TokenType tokenType) { + String subject = parseSubject(token, jwtProperties.secret()); + redisTemplate.opsForValue().set( + tokenType.addPrefix(subject), + token, + tokenType.getExpireTime(), + TimeUnit.MILLISECONDS + ); + return token; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java new file mode 100644 index 000000000..2605ad89f --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.AppleOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class AppleOAuthService extends OAuthService { + + private final AppleOAuthClient appleOAuthClient; + + public AppleOAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, + AppleOAuthClient appleOAuthClient, SignInService signInService) { + super(OAuthSignUpTokenProvider, siteUserRepository, signInService); + this.appleOAuthClient = appleOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return appleOAuthClient.processOAuth(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.APPLE; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java new file mode 100644 index 000000000..c2202ab2a --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.KakaoOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class KakaoOAuthService extends OAuthService { + + private final KakaoOAuthClient kakaoOAuthClient; + + public KakaoOAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, + KakaoOAuthClient kakaoOAuthClient, SignInService signInService) { + super(OAuthSignUpTokenProvider, siteUserRepository, signInService); + this.kakaoOAuthClient = kakaoOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return kakaoOAuthClient.getUserInfo(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.KAKAO; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java new file mode 100644 index 000000000..6e9bf7030 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.auth.service.oauth; + + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/* + * OAuth 제공자로부터 이메일을 받아 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. + * 기존 회원 : 로그인한다. + * 신규 회원 : 회원가입할 때 필요한 정보를 제공한다. + * */ +public abstract class OAuthService { + + private final OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + + protected OAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, SignInService signInService) { + this.OAuthSignUpTokenProvider = OAuthSignUpTokenProvider; + this.siteUserRepository = siteUserRepository; + this.signInService = signInService; + } + + @Transactional + public OAuthResponse processOAuth(OAuthCodeRequest oauthCodeRequest) { + OAuthUserInfoDto userInfoDto = getOAuthUserInfo(oauthCodeRequest.code()); + String email = userInfoDto.getEmail(); + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, getAuthType()); + + if (optionalSiteUser.isPresent()) { + SiteUser siteUser = optionalSiteUser.get(); + return getSignInResponse(siteUser); + } + + return getSignUpPrepareResponse(userInfoDto); + } + + protected final OAuthSignInResponse getSignInResponse(SiteUser siteUser) { + SignInResponse signInResponse = signInService.signIn(siteUser); + return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); + } + + protected final SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { + String signUpToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), getAuthType()); + return SignUpPrepareResponse.of(userInfoDto, signUpToken); + } + + protected abstract OAuthUserInfoDto getOAuthUserInfo(String code); + protected abstract AuthType getAuthType(); +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java new file mode 100644 index 000000000..a46728bb2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.auth.service.SignUpService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; + +@Service +public class OAuthSignUpService extends SignUpService { + + private final OAuthSignUpTokenProvider oAuthSignUpTokenProvider; + + OAuthSignUpService(SignInService signInService, SiteUserRepository siteUserRepository, + RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, + CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository, + OAuthSignUpTokenProvider oAuthSignUpTokenProvider) { + super(signInService, siteUserRepository, regionRepository, interestedRegionRepository, countryRepository, interestedCountyRepository); + this.oAuthSignUpTokenProvider = oAuthSignUpTokenProvider; + } + + @Override + protected void validateSignUpToken(SignUpRequest signUpRequest) { + oAuthSignUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); + } + + @Override + protected void validateUserNotDuplicated(SignUpRequest signUpRequest) { + String email = oAuthSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = oAuthSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + if (siteUserRepository.existsByEmailAndAuthType(email, authType)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + + @Override + protected SiteUser createSiteUser(SignUpRequest signUpRequest) { + String email = oAuthSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = oAuthSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + return signUpRequest.toOAuthSiteUser(email, authType); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java new file mode 100644 index 000000000..c3a95dbe9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java @@ -0,0 +1,81 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static com.example.solidconnection.util.JwtUtils.parseClaims; +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +public class OAuthSignUpTokenProvider extends TokenProvider { + + static final String AUTH_TYPE_CLAIM_KEY = "authType"; + + public OAuthSignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + super(jwtProperties, redisTemplate); + } + + public String generateAndSaveSignUpToken(String email, AuthType authType) { + Map authTypeClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, authType)); + Claims claims = Jwts.claims(authTypeClaim).setSubject(email); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); + + String signUpToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + return saveToken(signUpToken, TokenType.SIGN_UP); + } + + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + String email = parseEmail(token); + validateIssuedByServer(email); + } + + private void validateFormatAndExpiration(String token) { + try { + Claims claims = parseClaims(token, jwtProperties.secret()); + Objects.requireNonNull(claims.getSubject()); + String serializedAuthType = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + AuthType.valueOf(serializedAuthType); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + if (redisTemplate.opsForValue().get(key) == null) { + throw new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + } + } + + public String parseEmail(String token) { + return parseSubject(token, jwtProperties.secret()); + } + + public AuthType parseAuthType(String token) { + Claims claims = parseClaims(token, jwtProperties.secret()); + String authTypeStr = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(authTypeStr); + } +} diff --git a/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java b/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java new file mode 100644 index 000000000..c785168b3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.cache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CacheUpdateListener implements MessageListener { + + private final CompletableFutureManager futureManager; + + @Override + public void onMessage(Message message, byte[] pattern) { + String messageBody = new String(message.getBody(), StandardCharsets.UTF_8).replaceAll("^\"|\"$", ""); + futureManager.completeFuture(messageBody); + } +} diff --git a/src/main/java/com/example/solidconnection/cache/CachingAspect.java b/src/main/java/com/example/solidconnection/cache/CachingAspect.java new file mode 100644 index 000000000..816532022 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/CachingAspect.java @@ -0,0 +1,57 @@ +package com.example.solidconnection.cache; + +import com.example.solidconnection.cache.annotation.DefaultCacheOut; +import com.example.solidconnection.cache.annotation.DefaultCaching; +import com.example.solidconnection.cache.manager.CacheManager; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class CachingAspect { + + private final ApplicationContext applicationContext; + private final RedisUtils redisUtils; + + @Around("@annotation(defaultCaching)") + public Object cache(ProceedingJoinPoint joinPoint, DefaultCaching defaultCaching) throws Throwable { + + CacheManager cacheManager = (CacheManager) applicationContext.getBean(defaultCaching.cacheManager()); + String key = redisUtils.generateCacheKey(defaultCaching.key(), joinPoint.getArgs()); + Long ttl = defaultCaching.ttlSec(); + + // 1. 캐시에 있으면 반환 + Object cachedValue = cacheManager.get(key); + if (cachedValue != null) { + return cachedValue; + } + // 2. 캐시에 없으면 캐싱 후 반환 + Object result = joinPoint.proceed(); + cacheManager.put(key, result, ttl); + return result; + } + + @Around("@annotation(defaultCacheOut)") + public Object cacheEvict(ProceedingJoinPoint joinPoint, DefaultCacheOut defaultCacheOut) throws Throwable { + + CacheManager cacheManager = (CacheManager) applicationContext.getBean(defaultCacheOut.cacheManager()); + + for (String key : defaultCacheOut.key()) { + String cacheKey = redisUtils.generateCacheKey(key, joinPoint.getArgs()); + boolean usingPrefix = defaultCacheOut.prefix(); + + if (usingPrefix) { + cacheManager.evictUsingPrefix(cacheKey); + } else { + cacheManager.evict(cacheKey); + } + } + return joinPoint.proceed(); + } +} diff --git a/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java b/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java new file mode 100644 index 000000000..48c36b28c --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.cache; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class CompletableFutureManager { + + private final Map> waitingRequests = new ConcurrentHashMap<>(); + + public CompletableFuture getOrCreateFuture(String key) { + return waitingRequests.computeIfAbsent(key, k -> new CompletableFuture<>()); + } + + public void completeFuture(String key) { + CompletableFuture future = waitingRequests.remove(key); + if (future != null) { + future.complete(null); + } + } +} diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java new file mode 100644 index 000000000..a37e80f51 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -0,0 +1,158 @@ +package com.example.solidconnection.cache; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.cache.manager.CacheManager; +import com.example.solidconnection.util.RedisUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static com.example.solidconnection.type.RedisConstants.CREATE_CHANNEL; +import static com.example.solidconnection.type.RedisConstants.LOCK_TIMEOUT_MS; +import static com.example.solidconnection.type.RedisConstants.MAX_WAIT_TIME_MS; +import static com.example.solidconnection.type.RedisConstants.REFRESH_LIMIT_PERCENT; + +@Aspect +@Component +@Slf4j +public class ThunderingHerdCachingAspect { + + private final ApplicationContext applicationContext; + private final RedisTemplate redisTemplate; + private final CompletableFutureManager futureManager; + private final RedisUtils redisUtils; + + @Autowired + public ThunderingHerdCachingAspect(ApplicationContext applicationContext, RedisTemplate redisTemplate, + CompletableFutureManager futureManager, RedisUtils redisUtils) { + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + this.redisTemplate = redisTemplate; + this.applicationContext = applicationContext; + this.futureManager = futureManager; + this.redisUtils = redisUtils; + } + + @Around("@annotation(thunderingHerdCaching)") + public Object cache(ProceedingJoinPoint joinPoint, ThunderingHerdCaching thunderingHerdCaching) { + + CacheManager cacheManager = (CacheManager) applicationContext.getBean(thunderingHerdCaching.cacheManager()); + String key = redisUtils.generateCacheKey(thunderingHerdCaching.key(), joinPoint.getArgs()); + Long ttl = thunderingHerdCaching.ttlSec(); + + Object cachedValue = cacheManager.get(key); + if (cachedValue == null) { + log.info("Cache miss. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return createCache(joinPoint, cacheManager, ttl, key); + } + + if (redisUtils.isCacheExpiringSoon(key, ttl, Double.valueOf(REFRESH_LIMIT_PERCENT.getValue()))) { + log.info("Cache hit, but TTL is expiring soon. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return refreshCache(cachedValue, ttl, key); + } + + log.info("Cache hit. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return cachedValue; + } + + private Object createCache(ProceedingJoinPoint joinPoint, CacheManager cacheManager, Long ttl, String key) { + return executeWithLock( + redisUtils.getCreateLockKey(key), + () -> { + log.info("생성락 흭득하였습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + Object result = proceedJoinPoint(joinPoint); + cacheManager.put(key, result, ttl); + redisTemplate.convertAndSend(CREATE_CHANNEL.getValue(), key); + log.info("캐시 생성 후 채널에 pub 진행합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return result; + }, + () -> { + log.info("생성락 흭득에 실패하여 대기하러 갑니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return waitForCacheToUpdate(joinPoint, key); + } + ); + } + + private Object refreshCache(Object cachedValue, Long ttl, String key) { + return executeWithLock( + redisUtils.getRefreshLockKey(key), + () -> { + log.info("갱신락 흭득하였습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + redisTemplate.opsForValue().getAndExpire(key, Duration.ofSeconds(ttl)); + log.info("TTL 갱신을 마쳤습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return cachedValue; + }, + () -> { + log.info("갱신락 흭득에 실패하였습니다. 캐시의 값을 바로 반환합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return cachedValue; + } + ); + } + + private Object executeWithLock(String lockKey, Callable onLockAcquired, Callable onLockFailed) { + String lockValue = UUID.randomUUID().toString(); + boolean lockAcquired = false; + + try { + lockAcquired = tryAcquireLock(lockKey, lockValue); + if (lockAcquired) { + return onLockAcquired.call(); + } else { + return onLockFailed.call(); + } + } catch (Exception e) { + throw new RuntimeException("Error during executeWithLock", e); + } finally { + releaseLock(lockKey, lockValue, lockAcquired); + } + } + + private boolean tryAcquireLock(String lockKey, String lockValue) { + return redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofMillis(Long.parseLong(LOCK_TIMEOUT_MS.getValue()))); + } + + private void releaseLock(String lockKey, String lockValue, boolean lockAcquired) { + if (lockAcquired && lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { + redisTemplate.delete(lockKey); + log.info("락 반환합니다. Key: {}", lockKey); + } + } + + private Object waitForCacheToUpdate(ProceedingJoinPoint joinPoint, String key) { + CompletableFuture future = futureManager.getOrCreateFuture(key); + try { + future.get(Long.parseLong(MAX_WAIT_TIME_MS.getValue()), TimeUnit.MILLISECONDS); + log.info("대기에서 빠져나와 생성된 캐시값을 가져옵니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return redisTemplate.opsForValue().get(key); + } catch (TimeoutException e) { + log.warn("대기중 타임아웃 발생하여 DB 접근하여 반환합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return proceedJoinPoint(joinPoint); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Error during waitForCacheToUpdate", e); + } + } + + private Object proceedJoinPoint(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (Throwable e) { + throw new RuntimeException("Error during proceedJoinPoint", e); + } + } +} diff --git a/src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java new file mode 100644 index 000000000..2b5c8aada --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.cache.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DefaultCacheOut { + + String[] key(); + + String cacheManager(); + + boolean prefix() default false; +} diff --git a/src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java new file mode 100644 index 000000000..316daab0f --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.cache.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DefaultCaching { + + String key(); + + String cacheManager(); + + long ttlSec(); +} diff --git a/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java b/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java new file mode 100644 index 000000000..c5a9e0e9b --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.cache.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ThunderingHerdCaching { + String key(); + + String cacheManager(); + + long ttlSec(); +} diff --git a/src/main/java/com/example/solidconnection/cache/manager/CacheManager.java b/src/main/java/com/example/solidconnection/cache/manager/CacheManager.java new file mode 100644 index 000000000..3373d1563 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/manager/CacheManager.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.cache.manager; + +public interface CacheManager { + + void put(String key, Object value, Long ttl); + + Object get(String key); + + void evict(String key); + + void evictUsingPrefix(String key); +} diff --git a/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java b/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java new file mode 100644 index 000000000..2e489567c --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.cache.manager; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Set; + +@Component("customCacheManager") +public class CustomCacheManager implements CacheManager { + + private final RedisTemplate redisTemplate; + + @Autowired + public CustomCacheManager(RedisTemplate redisTemplate) { + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + this.redisTemplate = redisTemplate; + } + + public void put(String key, Object object, Long ttl) { + redisTemplate.opsForValue().set(key, object, Duration.ofSeconds(ttl)); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public void evict(String key) { + redisTemplate.delete(key); + } + + public void evictUsingPrefix(String key) { + Set keys = redisTemplate.keys(key + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java new file mode 100644 index 000000000..a87552796 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.community.board.controller; + +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.community.post.service.PostQueryService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.BoardCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 java.util.ArrayList; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/boards") +public class BoardController { + + private final PostQueryService postQueryService; + + // todo: 회원별로 접근 가능한 게시판 목록 조회 기능 개발 + @GetMapping + public ResponseEntity findAccessibleCodes() { + List accessibleCodeList = new ArrayList<>(); + for (BoardCode boardCode : BoardCode.values()) { + accessibleCodeList.add(String.valueOf(boardCode)); + } + return ResponseEntity.ok().body(accessibleCodeList); + } + + @GetMapping("/{code}") + public ResponseEntity findPostsByCodeAndCategory( + @AuthorizedUser SiteUser siteUser, + @PathVariable(value = "code") String code, + @RequestParam(value = "category", defaultValue = "전체") String category) { + List postsByCodeAndPostCategory = postQueryService + .findPostsByCodeAndPostCategory(code, category); + return ResponseEntity.ok().body(postsByCodeAndPostCategory); + } +} diff --git a/src/main/java/com/example/solidconnection/community/board/domain/Board.java b/src/main/java/com/example/solidconnection/community/board/domain/Board.java new file mode 100644 index 000000000..fbf13b44d --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/board/domain/Board.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.community.board.domain; + +import com.example.solidconnection.community.post.domain.Post; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +public class Board { + + @Id + @Column(length = 20) + private String code; + + @Column(nullable = false, length = 20) + private String koreanName; + + @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true) + private List postList = new ArrayList<>(); + + public Board(String code, String koreanName) { + this.code = code; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java b/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java new file mode 100644 index 000000000..e4f66afdd --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.community.board.dto; + +import com.example.solidconnection.community.board.domain.Board; + +public record PostFindBoardResponse( + String code, + String koreanName +) { + public static PostFindBoardResponse from(Board board) { + return new PostFindBoardResponse( + board.getCode(), + board.getKoreanName() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java b/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java new file mode 100644 index 000000000..06dd01161 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.community.board.repository; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; + +@Repository +public interface BoardRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"postList"}) + Optional findBoardByCode(@Param("code") String code); + + default Board getByCodeUsingEntityGraph(String code) { + return findBoardByCode(code) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_BOARD_CODE)); + } + + default Board getByCode(String code) { + return findById(code) + .orElseThrow(() -> new CustomException(INVALID_BOARD_CODE)); + } +} diff --git a/src/main/java/com/example/solidconnection/community/board/service/BoardService.java b/src/main/java/com/example/solidconnection/community/board/service/BoardService.java new file mode 100644 index 000000000..c918f8126 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/board/service/BoardService.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.community.board.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BoardService { +} diff --git a/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java b/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java new file mode 100644 index 000000000..d096f6cc9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java @@ -0,0 +1,56 @@ +package com.example.solidconnection.community.comment.controller; + +import com.example.solidconnection.community.comment.dto.CommentCreateRequest; +import com.example.solidconnection.community.comment.dto.CommentCreateResponse; +import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; +import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; +import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; +import com.example.solidconnection.community.comment.service.CommentService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments") +public class CommentController { + + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody CommentCreateRequest commentCreateRequest + ) { + CommentCreateResponse response = commentService.createComment(siteUser, commentCreateRequest); + return ResponseEntity.ok().body(response); + } + + @PatchMapping("/{comment_id}") + public ResponseEntity updateComment( + @AuthorizedUser SiteUser siteUser, + @PathVariable("comment_id") Long commentId, + @Valid @RequestBody CommentUpdateRequest commentUpdateRequest + ) { + CommentUpdateResponse response = commentService.updateComment(siteUser, commentId, commentUpdateRequest); + return ResponseEntity.ok().body(response); + } + + @DeleteMapping("/{comment_id}") + public ResponseEntity deleteCommentById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("comment_id") Long commentId + ) { + CommentDeleteResponse response = commentService.deleteCommentById(siteUser, commentId); + return ResponseEntity.ok().body(response); + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java b/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java new file mode 100644 index 000000000..abed4b8f0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java @@ -0,0 +1,121 @@ +package com.example.solidconnection.community.comment.domain; + +import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Transient; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = "id") +public class Comment extends BaseEntity { + + // for recursive query + @Transient + private int level; + + @Transient + private String path; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 255) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "site_user_id") + private SiteUser siteUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parentComment; + + @OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL) + private List commentList = new ArrayList<>(); + + public Comment(String content) { + this.content = content; + } + + public void setParentCommentAndPostAndSiteUser(Comment parentComment, Post post, SiteUser siteUser) { + + if (this.parentComment != null) { + this.parentComment.getCommentList().remove(this); + } + this.parentComment = parentComment; + parentComment.getCommentList().add(this); + + if (this.post != null) { + this.post.getCommentList().remove(this); + } + this.post = post; + post.getCommentList().add(this); + + if (this.siteUser != null) { + this.siteUser.getCommentList().remove(this); + } + this.siteUser = siteUser; + siteUser.getCommentList().add(this); + } + + public void setPostAndSiteUser(Post post, SiteUser siteUser) { + + if (this.post != null) { + this.post.getCommentList().remove(this); + } + this.post = post; + post.getCommentList().add(this); + + if (this.siteUser != null) { + this.siteUser.getCommentList().remove(this); + } + this.siteUser = siteUser; + siteUser.getCommentList().add(this); + } + + public void resetPostAndSiteUserAndParentComment() { + if (this.post != null) { + this.post.getCommentList().remove(this); + this.post = null; + } + if (this.siteUser != null) { + this.siteUser.getCommentList().remove(this); + this.siteUser = null; + } + if (this.parentComment != null) { + this.parentComment.getCommentList().remove(this); + this.parentComment = null; + } + } + + public void updateContent(String content) { + this.content = content; + } + + public void deprecateComment() { + this.content = null; + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java new file mode 100644 index 000000000..13c512a0c --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.community.comment.dto; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CommentCreateRequest( + @NotNull(message = "게시글 ID를 설정해주세요.") + Long postId, + + @NotBlank(message = "댓글 내용은 빈 값일 수 없습니다.") + @Size(min = 1, max = 255, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") + String content, + + Long parentId +) { + public Comment toEntity(SiteUser siteUser, Post post, Comment parentComment) { + + Comment comment = new Comment( + this.content + ); + + if (parentComment == null) { + comment.setPostAndSiteUser(post, siteUser); + } else { + comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); + } + return comment; + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateResponse.java new file mode 100644 index 000000000..58964f326 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.community.comment.dto; + +import com.example.solidconnection.community.comment.domain.Comment; + +public record CommentCreateResponse( + Long id +) { + + public static CommentCreateResponse from(Comment comment) { + return new CommentCreateResponse( + comment.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java new file mode 100644 index 000000000..5283bb87f --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.community.comment.dto; + +public record CommentDeleteResponse( + Long id +) { +} diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java new file mode 100644 index 000000000..6e14dab45 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.community.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용은 빈 값일 수 없습니다.") + @Size(min = 1, max = 255, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") + String content +) { +} diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateResponse.java new file mode 100644 index 000000000..5446753e4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.community.comment.dto; + +import com.example.solidconnection.community.comment.domain.Comment; + +public record CommentUpdateResponse( + Long id +) { + + public static CommentUpdateResponse from(Comment comment) { + return new CommentUpdateResponse( + comment.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java new file mode 100644 index 000000000..f1fd78ad0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.community.comment.dto; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; + +import java.time.ZonedDateTime; + +public record PostFindCommentResponse( + Long id, + Long parentId, + String content, + Boolean isOwner, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + PostFindSiteUserResponse postFindSiteUserResponse +) { + + public static PostFindCommentResponse from(Boolean isOwner, Comment comment) { + return new PostFindCommentResponse( + comment.getId(), + getParentCommentId(comment), + comment.getContent(), + isOwner, + comment.getCreatedAt(), + comment.getUpdatedAt(), + PostFindSiteUserResponse.from(comment.getSiteUser()) + ); + } + + private static Long getParentCommentId(Comment comment) { + if (comment.getParentComment() != null) { + return comment.getParentComment().getId(); + } + return null; + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java new file mode 100644 index 000000000..e5feb3f04 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.community.comment.repository; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.custom.exception.CustomException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_ID; + +public interface CommentRepository extends JpaRepository { + + @Query(value = """ + WITH RECURSIVE CommentTree AS ( + SELECT + id, parent_id, post_id, site_user_id, content, + created_at, updated_at, + 0 AS level, CAST(id AS CHAR(255)) AS path + FROM comment + WHERE post_id = :postId AND parent_id IS NULL + UNION ALL + SELECT + c.id, c.parent_id, c.post_id, c.site_user_id, c.content, + c.created_at, c.updated_at, + ct.level + 1, CONCAT(ct.path, '->', c.id) + FROM comment c + INNER JOIN CommentTree ct ON c.parent_id = ct.id + ) + SELECT * FROM CommentTree + ORDER BY path + """, nativeQuery = true) + List findCommentTreeByPostId(@Param("postId") Long postId); + + default Comment getById(Long id) { + return findById(id) + .orElseThrow(() -> new CustomException(INVALID_COMMENT_ID)); + } +} diff --git a/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java new file mode 100644 index 000000000..76138b356 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java @@ -0,0 +1,129 @@ +package com.example.solidconnection.community.comment.service; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.dto.CommentCreateRequest; +import com.example.solidconnection.community.comment.dto.CommentCreateResponse; +import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; +import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; +import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_LEVEL; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional(readOnly = true) + public List findCommentsByPostId(SiteUser siteUser, Long postId) { + return commentRepository.findCommentTreeByPostId(postId) + .stream() + .map(comment -> PostFindCommentResponse.from(isOwner(comment, siteUser), comment)) + .collect(Collectors.toList()); + } + + private Boolean isOwner(Comment comment, SiteUser siteUser) { + return comment.getSiteUser().getId().equals(siteUser.getId()); + } + + @Transactional + public CommentCreateResponse createComment(SiteUser siteUser, CommentCreateRequest commentCreateRequest) { + Post post = postRepository.getById(commentCreateRequest.postId()); + + Comment parentComment = null; + if (commentCreateRequest.parentId() != null) { + parentComment = commentRepository.getById(commentCreateRequest.parentId()); + validateCommentDepth(parentComment); + } + + /* + * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, + * siteUser 에 postList 를 FetchType.EAGER 로 설정할 것인지, + * post 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. + */ + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + Comment comment = commentCreateRequest.toEntity(siteUser1, post, parentComment); + Comment createdComment = commentRepository.save(comment); + + return CommentCreateResponse.from(createdComment); + } + + // 대대댓글부터 허용하지 않음 + private void validateCommentDepth(Comment parentComment) { + if (parentComment.getParentComment() != null) { + throw new CustomException(INVALID_COMMENT_LEVEL); + } + } + + @Transactional + public CommentUpdateResponse updateComment(SiteUser siteUser, Long commentId, CommentUpdateRequest commentUpdateRequest) { + Comment comment = commentRepository.getById(commentId); + validateDeprecated(comment); + validateOwnership(comment, siteUser); + + comment.updateContent(commentUpdateRequest.content()); + + return CommentUpdateResponse.from(comment); + } + + private void validateDeprecated(Comment comment) { + if (comment.getContent() == null) { + throw new CustomException(CAN_NOT_UPDATE_DEPRECATED_COMMENT); + } + } + + @Transactional + public CommentDeleteResponse deleteCommentById(SiteUser siteUser, Long commentId) { + Comment comment = commentRepository.getById(commentId); + validateOwnership(comment, siteUser); + + if (comment.getParentComment() != null) { + // 대댓글인 경우 + Comment parentComment = comment.getParentComment(); + // 대댓글을 삭제합니다. + comment.resetPostAndSiteUserAndParentComment(); + commentRepository.deleteById(commentId); + // 대댓글 삭제 이후, 부모댓글이 무의미하다면 이역시 삭제합니다. + if (parentComment.getCommentList().isEmpty() && parentComment.getContent() == null) { + parentComment.resetPostAndSiteUserAndParentComment(); + commentRepository.deleteById(parentComment.getId()); + } + } else { + // 댓글인 경우 + if (comment.getCommentList().isEmpty()) { + // 대댓글이 없는 경우 + comment.resetPostAndSiteUserAndParentComment(); + commentRepository.deleteById(commentId); + } else { + // 대댓글이 있는 경우 + comment.deprecateComment(); + } + } + return new CommentDeleteResponse(commentId); + } + + private void validateOwnership(Comment comment, SiteUser siteUser) { + if (!comment.getSiteUser().getId().equals(siteUser.getId())) { + throw new CustomException(INVALID_POST_ACCESS); + } + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/controller/PostController.java b/src/main/java/com/example/solidconnection/community/post/controller/PostController.java new file mode 100644 index 000000000..ee422930a --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/controller/PostController.java @@ -0,0 +1,106 @@ +package com.example.solidconnection.community.post.controller; + +import com.example.solidconnection.community.post.dto.PostCreateRequest; +import com.example.solidconnection.community.post.dto.PostCreateResponse; +import com.example.solidconnection.community.post.dto.PostDeleteResponse; +import com.example.solidconnection.community.post.dto.PostDislikeResponse; +import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateResponse; +import com.example.solidconnection.community.post.service.PostCommandService; +import com.example.solidconnection.community.post.service.PostLikeService; +import com.example.solidconnection.community.post.service.PostQueryService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collections; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +public class PostController { + + private final PostQueryService postQueryService; + private final PostCommandService postCommandService; + private final PostLikeService postLikeService; + + @PostMapping + public ResponseEntity createPost( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestPart("postCreateRequest") PostCreateRequest postCreateRequest, + @RequestParam(value = "file", required = false) List imageFile + ) { + if (imageFile == null) { + imageFile = Collections.emptyList(); + } + PostCreateResponse post = postCommandService.createPost(siteUser, postCreateRequest, imageFile); + return ResponseEntity.ok().body(post); + } + + @PatchMapping(value = "/{post_id}") + public ResponseEntity updatePost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("post_id") Long postId, + @Valid @RequestPart("postUpdateRequest") PostUpdateRequest postUpdateRequest, + @RequestParam(value = "file", required = false) List imageFile + ) { + if (imageFile == null) { + imageFile = Collections.emptyList(); + } + PostUpdateResponse postUpdateResponse = postCommandService.updatePost( + siteUser, postId, postUpdateRequest, imageFile + ); + return ResponseEntity.ok().body(postUpdateResponse); + } + + @GetMapping("/{post_id}") + public ResponseEntity findPostById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("post_id") Long postId + ) { + PostFindResponse postFindResponse = postQueryService.findPostById(siteUser, postId); + return ResponseEntity.ok().body(postFindResponse); + } + + @DeleteMapping(value = "/{post_id}") + public ResponseEntity deletePostById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("post_id") Long postId + ) { + PostDeleteResponse postDeleteResponse = postCommandService.deletePostById(siteUser, postId); + return ResponseEntity.ok().body(postDeleteResponse); + } + + @PostMapping(value = "/{post_id}/like") + public ResponseEntity likePost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("post_id") Long postId + ) { + PostLikeResponse postLikeResponse = postLikeService.likePost(siteUser, postId); + return ResponseEntity.ok().body(postLikeResponse); + } + + @DeleteMapping(value = "/{post_id}/like") + public ResponseEntity dislikePost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("post_id") Long postId + ) { + PostDislikeResponse postDislikeResponse = postLikeService.dislikePost(siteUser, postId); + return ResponseEntity.ok().body(postDislikeResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/domain/Post.java b/src/main/java/com/example/solidconnection/community/post/domain/Post.java new file mode 100644 index 000000000..4d96b9b22 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/domain/Post.java @@ -0,0 +1,112 @@ +package com.example.solidconnection.community.post.domain; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.PostCategory; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = "id") +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 255) + private String title; + + @Column(length = 1000) + private String content; + + private Boolean isQuestion; + + private Long likeCount; + + private Long viewCount; + + @Enumerated(EnumType.STRING) + private PostCategory category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_code") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "site_user_id") + private SiteUser siteUser; + + @BatchSize(size = 20) + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List commentList = new ArrayList<>(); + + @BatchSize(size = 5) + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postImageList = new ArrayList<>(); + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postLikeList = new ArrayList<>(); + + public Post(String title, String content, Boolean isQuestion, Long likeCount, Long viewCount, PostCategory category) { + this.title = title; + this.content = content; + this.isQuestion = isQuestion; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.category = category; + } + + public void setBoardAndSiteUser(Board board, SiteUser siteUser) { + if (this.board != null) { + this.board.getPostList().remove(this); + } + this.board = board; + board.getPostList().add(this); + + if (this.siteUser != null) { + this.siteUser.getPostList().remove(this); + } + this.siteUser = siteUser; + siteUser.getPostList().add(this); + } + + public void resetBoardAndSiteUser() { + if (this.board != null) { + this.board.getPostList().remove(this); + this.board = null; + } + if (this.siteUser != null) { + this.siteUser.getPostList().remove(this); + this.siteUser = null; + } + } + + public void update(PostUpdateRequest postUpdateRequest) { + this.title = postUpdateRequest.title(); + this.content = postUpdateRequest.content(); + this.category = PostCategory.valueOf(postUpdateRequest.postCategory()); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/domain/PostImage.java b/src/main/java/com/example/solidconnection/community/post/domain/PostImage.java new file mode 100644 index 000000000..5bf885741 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/domain/PostImage.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.community.post.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 500) + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + public PostImage(String url) { + this.url = url; + } + + public void setPost(Post post) { + if (this.post != null) { + this.post.getPostImageList().remove(this); + } + this.post = post; + post.getPostImageList().add(this); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java b/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java new file mode 100644 index 000000000..bbe1ff361 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.community.post.domain; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = "id") +public class PostLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "site_user_id") + private SiteUser siteUser; + + public void setPostAndSiteUser(Post post, SiteUser siteUser) { + if (this.post != null) { + this.post.getPostLikeList().remove(this); + } + this.post = post; + post.getPostLikeList().add(this); + + if (this.siteUser != null) { + this.siteUser.getPostLikeList().remove(this); + } + this.siteUser = siteUser; + siteUser.getPostLikeList().add(this); + } + + public void resetPostAndSiteUser() { + if (this.post != null) { + this.post.getPostLikeList().remove(this); + } + this.post = null; + + if (this.siteUser != null) { + this.siteUser.getPostLikeList().remove(this); + } + this.siteUser = null; + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java new file mode 100644 index 000000000..5e6590b20 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.PostCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PostCreateRequest( + @NotNull(message = "게시글 카테고리를 설정해주세요.") + String boardCode, + + @NotNull(message = "게시글 카테고리를 설정해주세요.") + String postCategory, + + @NotBlank(message = "게시글 제목은 빈 값일 수 없습니다.") + @Size(min = 1, max = 255, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") + String title, + + @NotBlank(message = "게시글 내용은 빈 값일 수 없습니다.") + @Size(min = 1, max = 1000, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") + String content, + + @NotNull(message = "게시글 질문여부를 설정해주세요.") + Boolean isQuestion +) { + + public Post toEntity(SiteUser siteUser, Board board) { + Post post = new Post( + this.title, + this.content, + this.isQuestion, + 0L, + 0L, + PostCategory.valueOf(this.postCategory) + ); + post.setBoardAndSiteUser(board, siteUser); + return post; + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostCreateResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateResponse.java new file mode 100644 index 000000000..51cc0c72e --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.post.domain.Post; + +public record PostCreateResponse( + Long id +) { + + public static PostCreateResponse from(Post post) { + return new PostCreateResponse( + post.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java new file mode 100644 index 000000000..f98f5264f --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.community.post.dto; + +public record PostDeleteResponse( + Long id +) { +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java new file mode 100644 index 000000000..83ffc8305 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.post.domain.Post; + +public record PostDislikeResponse( + Long likeCount, + Boolean isLiked +) { + public static PostDislikeResponse from(Post post) { + return new PostDislikeResponse( + post.getLikeCount(), + false + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java new file mode 100644 index 000000000..648bdb72c --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.post.domain.PostImage; + +import java.util.List; +import java.util.stream.Collectors; + +public record PostFindPostImageResponse( + Long id, + String url +) { + public static PostFindPostImageResponse from(PostImage postImage) { + return new PostFindPostImageResponse( + postImage.getId(), + postImage.getUrl() + ); + } + + public static List from(List postImageList) { + return postImageList.stream() + .map(PostFindPostImageResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java new file mode 100644 index 000000000..735defac1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.board.dto.PostFindBoardResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; + +import java.time.ZonedDateTime; +import java.util.List; + +public record PostFindResponse( + Long id, + String title, + String content, + Boolean isQuestion, + Long likeCount, + Long viewCount, + Integer commentCount, + String postCategory, + Boolean isOwner, + Boolean isLiked, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + PostFindBoardResponse postFindBoardResponse, + PostFindSiteUserResponse postFindSiteUserResponse, + List postFindCommentResponses, + List postFindPostImageResponses +) { + + public static PostFindResponse from(Post post, Boolean isOwner, Boolean isLiked, PostFindBoardResponse postFindBoardResponse, + PostFindSiteUserResponse postFindSiteUserResponse, + List postFindCommentResponses, + List postFindPostImageResponses + ) { + return new PostFindResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.getIsQuestion(), + post.getLikeCount(), + post.getViewCount(), + postFindCommentResponses.size(), + String.valueOf(post.getCategory()), + isOwner, + isLiked, + post.getCreatedAt(), + post.getUpdatedAt(), + postFindBoardResponse, + postFindSiteUserResponse, + postFindCommentResponses, + postFindPostImageResponses + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java new file mode 100644 index 000000000..35b2840c0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.post.domain.Post; + +public record PostLikeResponse( + Long likeCount, + Boolean isLiked +) { + public static PostLikeResponse from(Post post) { + return new PostLikeResponse( + post.getLikeCount(), + true + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java new file mode 100644 index 000000000..f02af017e --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.domain.Post; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public record PostListResponse( + Long id, + String title, + String content, + Long likeCount, + Integer commentCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + String postCategory, + String url +) { + + public static PostListResponse from(Post post) { + return new PostListResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.getLikeCount(), + getCommentCount(post), + post.getCreatedAt(), + post.getUpdatedAt(), + String.valueOf(post.getCategory()), + getFirstImageUrl(post) + ); + } + + public static List from(List postList) { + return postList.stream() + .map(PostListResponse::from) + .collect(Collectors.toList()); + } + + private static int getCommentCount(Post post) { + return post.getCommentList().size(); + } + + private static String getFirstImageUrl(Post post) { + return post.getPostImageList().stream() + .findFirst() + .map(PostImage::getUrl) + .orElse(null); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java new file mode 100644 index 000000000..339be3519 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.community.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PostUpdateRequest( + @NotNull(message = "게시글 카테고리를 설정해주세요.") + String postCategory, + + @NotBlank(message = "게시글 제목은 빈 값일 수 없습니다.") + @Size(min = 1, max = 255, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") + String title, + + @NotBlank(message = "게시글 내용은 빈 값일 수 없습니다.") + @Size(min = 1, max = 1000, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") + String content +) { +} diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java new file mode 100644 index 000000000..5c35f031d --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.community.post.dto; + +import com.example.solidconnection.community.post.domain.Post; + +public record PostUpdateResponse( + Long id +) { + public static PostUpdateResponse from(Post post) { + return new PostUpdateResponse( + post.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java new file mode 100644 index 000000000..54c43f375 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.community.post.repository; + +import com.example.solidconnection.community.post.domain.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java new file mode 100644 index 000000000..417e97310 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.community.post.repository; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostLike; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_LIKE; + +@Repository +public interface PostLikeRepository extends JpaRepository { + + Optional findPostLikeByPostAndSiteUser(Post post, SiteUser siteUser); + + default PostLike getByPostAndSiteUser(Post post, SiteUser siteUser) { + return findPostLikeByPostAndSiteUser(post, siteUser) + .orElseThrow(() -> new CustomException(INVALID_POST_LIKE)); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java new file mode 100644 index 000000000..336189b05 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.community.post.repository; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ID; + +@Repository +public interface PostRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"postImageList", "board", "siteUser"}) + Optional findPostById(Long id); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Post p SET p.likeCount = p.likeCount - 1 + WHERE p.id = :postId AND p.likeCount > 0 + """) + void decreaseLikeCount(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Post p SET p.likeCount = p.likeCount + 1 + WHERE p.id = :postId + """) + void increaseLikeCount(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Post p SET p.viewCount = p.viewCount + :count + WHERE p.id = :postId + """) + void increaseViewCount(@Param("postId") Long postId, @Param("count") Long count); + + default Post getByIdUsingEntityGraph(Long id) { + return findPostById(id) + .orElseThrow(() -> new CustomException(INVALID_POST_ID)); + } + + default Post getById(Long id) { + return findById(id) + .orElseThrow(() -> new CustomException(INVALID_POST_ID)); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java new file mode 100644 index 000000000..b95cbcf1b --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java @@ -0,0 +1,148 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.dto.PostCreateRequest; +import com.example.solidconnection.community.post.dto.PostCreateResponse; +import com.example.solidconnection.community.post.dto.PostDeleteResponse; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateResponse; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; +import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class PostCommandService { + + private final PostRepository postRepository; + private final BoardRepository boardRepository; + private final S3Service s3Service; + private final RedisService redisService; + private final RedisUtils redisUtils; + private final SiteUserRepository siteUserRepository; + + @Transactional + public PostCreateResponse createPost(SiteUser siteUser, PostCreateRequest postCreateRequest, + List imageFile) { + // 유효성 검증 + validatePostCategory(postCreateRequest.postCategory()); + validateFileSize(imageFile); + + // 객체 생성 + Board board = boardRepository.getByCode(postCreateRequest.boardCode()); + /* + * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, + * siteUser 에 postList 를 FetchType.EAGER 로 설정할 것인지, + * post 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. + */ + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + Post post = postCreateRequest.toEntity(siteUser1, board); + // 이미지 처리 + savePostImages(imageFile, post); + Post createdPost = postRepository.save(post); + + return PostCreateResponse.from(createdPost); + } + + @Transactional + public PostUpdateResponse updatePost(SiteUser siteUser, Long postId, PostUpdateRequest postUpdateRequest, + List imageFile) { + // 유효성 검증 + Post post = postRepository.getById(postId); + validateOwnership(post, siteUser); + validateQuestion(post); + validateFileSize(imageFile); + + // 기존 사진 모두 삭제 + removePostImages(post); + // 새로운 이미지 등록 + savePostImages(imageFile, post); + // 게시글 내용 수정 + post.update(postUpdateRequest); + + return PostUpdateResponse.from(post); + } + + private void savePostImages(List imageFile, Post post) { + if (imageFile.isEmpty()) { + return; + } + List uploadedFileUrlResponseList = s3Service.uploadFiles(imageFile, ImgType.COMMUNITY); + for (UploadedFileUrlResponse uploadedFileUrlResponse : uploadedFileUrlResponseList) { + PostImage postImage = new PostImage(uploadedFileUrlResponse.fileUrl()); + postImage.setPost(post); + } + } + + @Transactional + public PostDeleteResponse deletePostById(SiteUser siteUser, Long postId) { + Post post = postRepository.getById(postId); + validateOwnership(post, siteUser); + validateQuestion(post); + + removePostImages(post); + post.resetBoardAndSiteUser(); + // cache out + redisService.deleteKey(redisUtils.getPostViewCountRedisKey(postId)); + postRepository.deleteById(post.getId()); + + return new PostDeleteResponse(postId); + } + + private void validateOwnership(Post post, SiteUser siteUser) { + if (!post.getSiteUser().getId().equals(siteUser.getId())) { + throw new CustomException(INVALID_POST_ACCESS); + } + } + + private void validateFileSize(List imageFile) { + if (imageFile.isEmpty()) { + return; + } + if (imageFile.size() > 5) { + throw new CustomException(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES); + } + } + + private void validateQuestion(Post post) { + if (post.getIsQuestion()) { + throw new CustomException(CAN_NOT_DELETE_OR_UPDATE_QUESTION); + } + } + + private void validatePostCategory(String category) { + if (!EnumUtils.isValidEnum(PostCategory.class, category) || category.equals(PostCategory.전체.toString())) { + throw new CustomException(INVALID_POST_CATEGORY); + } + } + + private void removePostImages(Post post) { + for (PostImage postImage : post.getPostImageList()) { + s3Service.deletePostImage(postImage.getUrl()); + } + post.getPostImageList().clear(); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java b/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java new file mode 100644 index 000000000..98d1a239f --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java @@ -0,0 +1,64 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostLike; +import com.example.solidconnection.community.post.dto.PostDislikeResponse; +import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; +import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class PostLikeService { + + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public PostLikeResponse likePost(SiteUser siteUser, Long postId) { + Post post = postRepository.getById(postId); + validateDuplicatePostLike(post, siteUser); + PostLike postLike = new PostLike(); + + /* + * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, + * siteUser 에 postList 를 FetchType.EAGER 로 설정할 것인지, + * post 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. + */ + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + postLike.setPostAndSiteUser(post, siteUser1); + postLikeRepository.save(postLike); + postRepository.increaseLikeCount(post.getId()); + + return PostLikeResponse.from(postRepository.getById(postId)); // 실시간성을 위한 재조회 + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public PostDislikeResponse dislikePost(SiteUser siteUser, Long postId) { + Post post = postRepository.getById(postId); + + PostLike postLike = postLikeRepository.getByPostAndSiteUser(post, siteUser); + postLike.resetPostAndSiteUser(); + postLikeRepository.deleteById(postLike.getId()); + postRepository.decreaseLikeCount(post.getId()); + + return PostDislikeResponse.from(postRepository.getById(postId)); // 실시간성을 위한 재조회 + } + + private void validateDuplicatePostLike(Post post, SiteUser siteUser) { + if (postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser).isPresent()) { + throw new CustomException(DUPLICATE_POST_LIKE); + } + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java new file mode 100644 index 000000000..66cbb5faa --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java @@ -0,0 +1,107 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.dto.PostFindBoardResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.comment.service.CommentService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostFindPostImageResponse; +import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; + +@Service +@RequiredArgsConstructor +public class PostQueryService { + + private final BoardRepository boardRepository; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final CommentService commentService; + private final RedisService redisService; + private final RedisUtils redisUtils; + + @Transactional(readOnly = true) + public List findPostsByCodeAndPostCategory(String code, String category) { + + String boardCode = validateCode(code); + PostCategory postCategory = validatePostCategory(category); + + Board board = boardRepository.getByCodeUsingEntityGraph(boardCode); + List postList = getPostListByPostCategory(board.getPostList(), postCategory); + + return PostListResponse.from(postList); + } + + @Transactional(readOnly = true) + public PostFindResponse findPostById(SiteUser siteUser, Long postId) { + Post post = postRepository.getByIdUsingEntityGraph(postId); + Boolean isOwner = getIsOwner(post, siteUser); + Boolean isLiked = getIsLiked(post, siteUser); + + PostFindBoardResponse boardPostFindResultDTO = PostFindBoardResponse.from(post.getBoard()); + PostFindSiteUserResponse siteUserPostFindResultDTO = PostFindSiteUserResponse.from(post.getSiteUser()); + List postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList()); + List commentFindResultDTOList = commentService.findCommentsByPostId(siteUser, postId); + + // caching && 어뷰징 방지 + if (redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), postId))) { + redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(postId)); + } + + return PostFindResponse.from( + post, isOwner, isLiked, boardPostFindResultDTO, siteUserPostFindResultDTO, commentFindResultDTOList, postImageFindResultDTOList); + } + + private String validateCode(String code) { + try { + return String.valueOf(BoardCode.valueOf(code)); + } catch (IllegalArgumentException ex) { + throw new CustomException(INVALID_BOARD_CODE); + } + } + + private Boolean getIsOwner(Post post, SiteUser siteUser) { + return post.getSiteUser().getId().equals(siteUser.getId()); + } + + private Boolean getIsLiked(Post post, SiteUser siteUser) { + return postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser) + .isPresent(); + } + + private PostCategory validatePostCategory(String category) { + if (!EnumUtils.isValidEnum(PostCategory.class, category)) { + throw new CustomException(INVALID_POST_CATEGORY); + } + return PostCategory.valueOf(category); + } + + private List getPostListByPostCategory(List postList, PostCategory postCategory) { + if (postCategory.equals(PostCategory.전체)) { + return postList; + } + return postList.stream() + .filter(post -> post.getCategory().equals(postCategory)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java new file mode 100644 index 000000000..609e9ee89 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.apple") +public record AppleOAuthClientProperties( + String tokenUrl, + String clientSecretAudienceUrl, + String redirectUrl, + String publicKeyUrl, + String clientId, + String teamId, + String keyId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java new file mode 100644 index 000000000..73b196d76 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.kakao") +public record KakaoOAuthClientProperties( + String tokenUrl, + String userInfoUrl, + String redirectUrl, + String clientId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java new file mode 100644 index 000000000..36ce3f67b --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java new file mode 100644 index 000000000..22847dc6d --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java @@ -0,0 +1,71 @@ +package com.example.solidconnection.config.redis; + +import com.example.solidconnection.cache.CacheUpdateListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import static com.example.solidconnection.type.RedisConstants.CREATE_CHANNEL; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + private final String redisHost; + private final int redisPort; + + public RedisConfig(@Value("${spring.data.redis.host}") final String redisHost, + @Value("${spring.data.redis.port}") final int redisPort) { + this.redisHost = redisHost; + this.redisPort = redisPort; + } + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + + @Bean + public RedisTemplate objectRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + + @Bean + RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory, + CacheUpdateListener listener) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(listener, new PatternTopic(CREATE_CHANNEL.getValue())); + return container; + } + + @Bean(name = "incrViewCountScript") + public RedisScript incrViewCountLuaScript() { + Resource scriptSource = new ClassPathResource("scripts/incrViewCount.lua"); + return RedisScript.of(scriptSource, Long.class); + } +} diff --git a/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java b/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java new file mode 100644 index 000000000..2a2cfa6a5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.config.scheduler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +@Configuration +public class SchedulerConfig implements SchedulingConfigurer { + + private final int POOL_SIZE = 5; + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + + final ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + threadPoolTaskScheduler.setPoolSize(POOL_SIZE); + threadPoolTaskScheduler.setThreadNamePrefix("Scheduler-"); + threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true); + threadPoolTaskScheduler.setAwaitTerminationSeconds(10); + threadPoolTaskScheduler.initialize(); + + taskRegistrar.setTaskScheduler(threadPoolTaskScheduler); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java b/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java new file mode 100644 index 000000000..785283d7d --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.config.security; + +import com.example.solidconnection.custom.security.provider.ExpiredTokenAuthenticationProvider; +import com.example.solidconnection.custom.security.provider.SiteUserAuthenticationProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; + +@RequiredArgsConstructor +@Configuration +public class AuthenticationManagerConfig { + + private final SiteUserAuthenticationProvider siteUserAuthenticationProvider; + private final ExpiredTokenAuthenticationProvider expiredTokenAuthenticationProvider; + + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager( + siteUserAuthenticationProvider, + expiredTokenAuthenticationProvider + ); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/CorsProperties.java b/src/main/java/com/example/solidconnection/config/security/CorsProperties.java new file mode 100644 index 000000000..f851692c6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/CorsProperties.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.config.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "cors") +public record CorsProperties(List allowedOrigins) { +} diff --git a/src/main/java/com/example/solidconnection/config/security/JwtProperties.java b/src/main/java/com/example/solidconnection/config/security/JwtProperties.java new file mode 100644 index 000000000..e0c63da46 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/JwtProperties.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.config.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties(String secret) { +} diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java new file mode 100644 index 000000000..1d0b110bb --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -0,0 +1,77 @@ +package com.example.solidconnection.config.security; + +import com.example.solidconnection.custom.exception.CustomAccessDeniedHandler; +import com.example.solidconnection.custom.exception.CustomAuthenticationEntryPoint; +import com.example.solidconnection.custom.security.filter.ExceptionHandlerFilter; +import com.example.solidconnection.custom.security.filter.JwtAuthenticationFilter; +import com.example.solidconnection.custom.security.filter.SignOutCheckFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import static com.example.solidconnection.type.Role.ADMIN; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfiguration { + + private final CorsProperties corsProperties; + private final ExceptionHandlerFilter exceptionHandlerFilter; + private final SignOutCheckFilter signOutCheckFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); + configuration.addAllowedMethod("*"); + configuration.addAllowedHeader("*"); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/**").hasRole(ADMIN.name()) + .anyRequest().permitAll() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) + .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) + .addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class) + .addFilterBefore(exceptionHandlerFilter, SignOutCheckFilter.class) + .build(); + } +} diff --git a/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java b/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java new file mode 100644 index 000000000..417b040b3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.config.sync; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + @Bean(name = "asyncExecutor") + public ThreadPoolTaskExecutor asyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("Async-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(10); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java new file mode 100644 index 000000000..10e468f56 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.config.web; + + +import com.example.solidconnection.custom.resolver.AuthorizedUserResolver; +import com.example.solidconnection.custom.resolver.ExpiredTokenResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthorizedUserResolver authorizedUserResolver; + private final ExpiredTokenResolver expiredTokenResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.addAll(List.of( + authorizedUserResolver, + expiredTokenResolver + )); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java b/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java new file mode 100644 index 000000000..52b1725fc --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + ErrorResponse errorResponse = + new ErrorResponse(ErrorCode.ACCESS_DENIED); + response.setStatus(ErrorCode.ACCESS_DENIED.getCode()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java new file mode 100644 index 000000000..20f0786b7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + ErrorResponse errorResponse = + new ErrorResponse(ErrorCode.AUTHENTICATION_FAILED); + response.setStatus(ErrorCode.AUTHENTICATION_FAILED.getCode()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomException.java b/src/main/java/com/example/solidconnection/custom/exception/CustomException.java new file mode 100644 index 000000000..2f1962fbf --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomException.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.custom.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final int code; + private final String message; + + public CustomException(ErrorCode errorCode) { + code = errorCode.getCode(); + message = errorCode.getMessage(); + } + + public CustomException(ErrorCode errorCode, String detail) { + code = errorCode.getCode(); + message = errorCode.getMessage() + " : " + detail; + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java b/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java new file mode 100644 index 000000000..c0c610bce --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java @@ -0,0 +1,78 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import io.jsonwebtoken.JwtException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.ArrayList; +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_INPUT; +import static com.example.solidconnection.custom.exception.ErrorCode.JSON_PARSING_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.JWT_EXCEPTION; +import static com.example.solidconnection.custom.exception.ErrorCode.NOT_DEFINED_ERROR; + +@Slf4j +@ControllerAdvice +public class CustomExceptionHandler { + + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException ex) { + log.error("커스텀 예외 발생 : {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex); + return ResponseEntity + .status(ex.getCode()) + .body(errorResponse); + } + + @ExceptionHandler(InvalidFormatException.class) + public ResponseEntity handleInvalidFormatException(InvalidFormatException ex) { + String errorMessage = ex.getValue() + " 은(는) 유효하지 않은 값입니다."; + log.error("JSON 파싱 예외 발생 : {}", errorMessage); + ErrorResponse errorResponse = new ErrorResponse(JSON_PARSING_FAILED, errorMessage); + return ResponseEntity + .status(JSON_PARSING_FAILED.getCode()) + .body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + List errors = new ArrayList<>(); + ex.getBindingResult() + .getFieldErrors() + .forEach(fieldError -> errors.add(fieldError.getDefaultMessage())); + + String errorMessage = errors.toString(); + log.error("입력값 검증 예외 발생 : {}", errorMessage); + ErrorResponse errorResponse = new ErrorResponse(INVALID_INPUT, errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errorResponse); + } + + @ExceptionHandler(JwtException.class) + public ResponseEntity handleJwtException(JwtException ex) { + String errorMessage = ex.getMessage(); + log.error("JWT 예외 발생 : {}", errorMessage); + ErrorResponse errorResponse = new ErrorResponse(JWT_EXCEPTION, errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleOtherException(Exception ex) { + String errorMessage = ex.getMessage(); + log.error("서버 내부 예외 발생 : {}", errorMessage); + ErrorResponse errorResponse = new ErrorResponse(NOT_DEFINED_ERROR, errorMessage); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java new file mode 100644 index 000000000..dff7b13f0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -0,0 +1,106 @@ +package com.example.solidconnection.custom.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; +import static com.example.solidconnection.siteuser.service.SiteUserService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + // apple + APPLE_AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST.value(), "애플 인증에 실패했습니다."), + APPLE_ID_TOKEN_MISSING_EMAIL(HttpStatus.BAD_REQUEST.value(), "애플 idToken 에 이메일이 없습니다."), + APPLE_ID_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 이 만료되었습니다."), + INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 애플 idToken 입니다."), + APPLE_ID_TOKEN_MALFORMED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 의 형식이 잘못되었습니다."), + APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR.value(), "idToken 를 서명한 애플 공개키를 찾을 수 없습니다"), + FAILED_TO_READ_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 private key 파일을 읽을 수 없습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 client secret JWT 생성에 실패했습니다."), + + // kakao + KAKAO_REDIRECT_URI_MISMATCH(HttpStatus.BAD_REQUEST.value(), "리다이렉트 uri가 잘못되었습니다."), + INVALID_OR_EXPIRED_KAKAO_AUTH_CODE(HttpStatus.BAD_REQUEST.value(), "사용할 수 없는 카카오 인증 코드입니다. 카카오 인증 코드는 일회용이며, 인증 만료 시간은 10분입니다."), + KAKAO_ACCESS_TOKEN_FAIL(HttpStatus.NOT_FOUND.value(), "카카오 엑세스 토큰 발급에 실패했습니다"), + KAKAO_USER_INFO_FAIL(HttpStatus.BAD_REQUEST.value(), "카카오 사용자 정보 조회에 실패했습니다."), + INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN(HttpStatus.BAD_REQUEST.value(), "우리 서비스에서 발급한 카카오 토큰이 아닙니다"), + + // sign up token + SIGN_UP_TOKEN_INVALID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 회원가입 토큰입니다."), + SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER(HttpStatus.BAD_REQUEST.value(), "회원가입 토큰이 우리 서버에서 발급되지 않았습니다."), + + // data not found + UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 대학교 지원 정보입니다."), + UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM(HttpStatus.NOT_FOUND.value(), "해당하는 대학교가 이번 모집 기간에 열리지 않았습니다."), + APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "사용자의 대학 지원 정보를 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "회원을 찾을 수 없습니다."), + UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "대학교를 찾을 수 없습니다."), + REGION_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 지역을 찾을 수 없습니다."), + COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."), + + // auth + USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), + EMPTY_TOKEN(HttpStatus.UNAUTHORIZED.value(), "토큰이 필요한 경로에 빈 토큰으로 요청했습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED.value(), "인증이 필요한 접근입니다."), + ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), + ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + + // s3 + S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), + S3_CLIENT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 클라이언트 에러 발생"), + FILE_NOT_EXIST(HttpStatus.BAD_REQUEST.value(), "파일이 없습니다."), + NOT_ALLOWED_FILE_EXTENSIONS(HttpStatus.BAD_REQUEST.value(), "허용되지 않은 확장자입니다."), + INVALID_FILE_EXTENSIONS(HttpStatus.BAD_REQUEST.value(), "파일 형식이 유효하지 않습니다."), + + // invalid operation + SCORE_SHOULD_SUBMITTED_FIRST(HttpStatus.BAD_REQUEST.value(), "성적을 먼저 제출해주세요."), + USER_ALREADY_EXISTED(HttpStatus.CONFLICT.value(), "이미 존재하는 회원입니다."), + NICKNAME_ALREADY_EXISTED(HttpStatus.CONFLICT.value(), "이미 존재하는 닉네임입니다."), + INVALID_TEST_TYPE(HttpStatus.BAD_REQUEST.value(), "지원하지 않은 어학 시험 종류입니다."), + APPLICATION_NOT_APPROVED(HttpStatus.BAD_REQUEST.value(), "성적표가 인증되지 않았습니다."), + APPLY_UPDATE_LIMIT_EXCEED(HttpStatus.BAD_REQUEST.value(), "지원 정보 수정은 " + APPLICATION_UPDATE_COUNT_LIMIT + "회까지만 가능합니다."), + CANT_APPLY_FOR_SAME_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "1, 2, 3지망에 동일한 대학교를 입력할 수 없습니다."), + CAN_NOT_CHANGE_NICKNAME_YET(HttpStatus.BAD_REQUEST.value(), "마지막 닉네임 변경으로부터 " + MIN_DAYS_BETWEEN_NICKNAME_CHANGES + "일이 지나지 않았습니다."), + PROFILE_IMAGE_NEEDED(HttpStatus.BAD_REQUEST.value(), "프로필 이미지가 필요합니다."), + FIRST_CHOICE_REQUIRED(HttpStatus.BAD_REQUEST.value(), "1지망 대학교를 입력해주세요."), + THIRD_CHOICE_REQUIRES_SECOND(HttpStatus.BAD_REQUEST.value(), "2지망 없이 3지망을 선택할 수 없습니다."), + DUPLICATE_UNIVERSITY_CHOICE(HttpStatus.BAD_REQUEST.value(), "지망 선택이 중복되었습니다."), + + // community + INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(), "잘못된 카테고리명입니다."), + INVALID_BOARD_CODE(HttpStatus.BAD_REQUEST.value(), "잘못된 게시판 코드입니다."), + INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), + INVALID_POST_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 게시글만 제어할 수 있습니다."), + CAN_NOT_DELETE_OR_UPDATE_QUESTION(HttpStatus.BAD_REQUEST.value(), "질문글은 수정이나 삭제할 수 없습니다."), + CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES(HttpStatus.BAD_REQUEST.value(), "5개 이상의 파일을 업로드할 수 없습니다."), + INVALID_COMMENT_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 댓글입니다."), + INVALID_COMMENT_LEVEL(HttpStatus.BAD_REQUEST.value(), "최대 대댓글까지만 작성할 수 있습니다."), + INVALID_COMMENT_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 댓글만 제어할 수 있습니다."), + CAN_NOT_UPDATE_DEPRECATED_COMMENT(HttpStatus.BAD_REQUEST.value(), "이미 삭제된 댓글을 수정할 수 없습니다."), + INVALID_POST_LIKE(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글 좋아요입니다."), + DUPLICATE_POST_LIKE(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 게시글입니다."), + ALREADY_LIKED_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 대학입니다."), + NOT_LIKED_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 대학입니다."), + + // score + INVALID_GPA_SCORE(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 학점입니다."), + INVALID_GPA_SCORE_STATUS(HttpStatus.BAD_REQUEST.value(), "학점이 승인되지 않았습니다."), + INVALID_LANGUAGE_TEST_SCORE(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 어학성적입니다."), + INVALID_LANGUAGE_TEST_SCORE_STATUS(HttpStatus.BAD_REQUEST.value(), "어학성적이 승인되지 않았습니다."), + USER_DO_NOT_HAVE_GPA(HttpStatus.BAD_REQUEST.value(), "해당 유저의 학점을 찾을 수 없음"), + + // general + JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), + JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), + INVALID_INPUT(HttpStatus.BAD_REQUEST.value(), "값을 입력할 수 없습니다."), + NOT_DEFINED_ERROR(HttpStatus.BAD_REQUEST.value(), "에러가 발생했습니다."), + ; + + private final int code; + private final String message; +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/JwtExpiredTokenException.java b/src/main/java/com/example/solidconnection/custom/exception/JwtExpiredTokenException.java new file mode 100644 index 000000000..b0a52e9fa --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/JwtExpiredTokenException.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.custom.exception; + +import org.springframework.security.core.AuthenticationException; + +public class JwtExpiredTokenException extends AuthenticationException { + + public JwtExpiredTokenException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java new file mode 100644 index 000000000..fa1db7f74 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.custom.resolver; + +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 AuthorizedUser { + boolean required() default true; +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java new file mode 100644 index 000000000..f4ba9fe7f --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java @@ -0,0 +1,50 @@ +package com.example.solidconnection.custom.resolver; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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 static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; + +@Component +@RequiredArgsConstructor +public class AuthorizedUserResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthorizedUser.class) + && parameter.getParameterType().equals(SiteUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + SiteUser siteUser = extractSiteUserFromAuthentication(); + if (parameter.getParameterAnnotation(AuthorizedUser.class).required() && siteUser == null) { + throw new CustomException(AUTHENTICATION_FAILED, "로그인 상태가 아닙니다."); + } + + return siteUser; + } + + private SiteUser extractSiteUserFromAuthentication() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + SiteUserDetails principal = (SiteUserDetails) authentication.getPrincipal(); + return principal.getSiteUser(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java new file mode 100644 index 000000000..5de4ad95a --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.custom.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExpiredToken { +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java new file mode 100644 index 000000000..7547a1d61 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.custom.resolver; + +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +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; + +// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 +@Component +@RequiredArgsConstructor +public class ExpiredTokenResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ExpiredToken.class) + && parameter.getParameterType().equals(ExpiredTokenAuthentication.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + try { + return SecurityContextHolder.getContext().getAuthentication(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java b/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java new file mode 100644 index 000000000..83cc02622 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.custom.response; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; + +public record ErrorResponse(String message) { + + public ErrorResponse(CustomException e) { + this(e.getMessage()); + } + + public ErrorResponse(ErrorCode e) { + this(e.getMessage()); + } + + public ErrorResponse(ErrorCode e, String detail) { + this(e.getMessage() + " : " + detail); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java new file mode 100644 index 000000000..061484674 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.custom.security.authentication; + +// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 +public class ExpiredTokenAuthentication extends JwtAuthentication { + + public ExpiredTokenAuthentication(String token) { + super(token, null); + setAuthenticated(false); + } + + public ExpiredTokenAuthentication(String token, String subject) { + super(token, subject); + setAuthenticated(false); + } + + public String getSubject() { + return (String) getPrincipal(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java new file mode 100644 index 000000000..6c9f2fa21 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.custom.security.authentication; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collections; + +public abstract class JwtAuthentication extends AbstractAuthenticationToken { + + private final String credentials; + + private final Object principal; + + public JwtAuthentication(String token, Object principal) { + super(principal instanceof UserDetails ? + ((UserDetails) principal).getAuthorities() : + Collections.emptyList()); + this.credentials = token; + this.principal = principal; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + public final String getToken() { + return (String) getCredentials(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java new file mode 100644 index 000000000..3387cee55 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.custom.security.authentication; + +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; + +public class SiteUserAuthentication extends JwtAuthentication { + + public SiteUserAuthentication(String token) { + super(token, null); + setAuthenticated(false); + } + + public SiteUserAuthentication(String token, SiteUserDetails principal) { + super(token, principal); + setAuthenticated(true); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java new file mode 100644 index 000000000..2db133b8f --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java @@ -0,0 +1,57 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; + +@Component +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomException e) { + customCommence(response, e); + } catch (Exception e) { + generalCommence(response, e, AUTHENTICATION_FAILED); + } + } + + public void customCommence(HttpServletResponse response, CustomException customException) throws IOException { + ErrorResponse errorResponse = new ErrorResponse(customException); + writeResponse(response, errorResponse, customException.getCode()); + } + + public void generalCommence(HttpServletResponse response, Exception exception, ErrorCode errorCode) throws IOException { + ErrorResponse errorResponse = new ErrorResponse(errorCode, exception.getMessage()); + writeResponse(response, errorResponse, errorCode.getCode()); + } + + private void writeResponse(HttpServletResponse response, ErrorResponse errorResponse, int statusCode) throws IOException { + SecurityContextHolder.clearContext(); + response.setStatus(statusCode); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..3f5bce556 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.custom.security.authentication.JwtAuthentication; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.solidconnection.util.JwtUtils.isExpired; +import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; + + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProperties jwtProperties; + private final AuthenticationManager authenticationManager; + + @Override + public void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String token = parseTokenFromRequest(request); + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + JwtAuthentication authToken = createAuthentication(token); + Authentication auth = authenticationManager.authenticate(authToken); + SecurityContextHolder.getContext().setAuthentication(auth); + + filterChain.doFilter(request, response); + } + + private JwtAuthentication createAuthentication(String token) { + if (isExpired(token, jwtProperties.secret())) { + return new ExpiredTokenAuthentication(token); + } + return new SiteUserAuthentication(token); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java new file mode 100644 index 000000000..2cef8d1ac --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java @@ -0,0 +1,39 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.custom.exception.CustomException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; +import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; + +@Component +@RequiredArgsConstructor +public class SignOutCheckFilter extends OncePerRequestFilter { + + private final AuthTokenProvider authTokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String token = parseTokenFromRequest(request); + if (token != null && hasSignedOut(token)) { + throw new CustomException(USER_ALREADY_SIGN_OUT); + } + filterChain.doFilter(request, response); + } + + private boolean hasSignedOut(String accessToken) { + return authTokenProvider.findBlackListToken(accessToken).isPresent(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java b/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java new file mode 100644 index 000000000..01b065a19 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.custom.security.provider; + + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.custom.security.authentication.JwtAuthentication; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; + +// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 +@Component +@RequiredArgsConstructor +public class ExpiredTokenAuthenticationProvider implements AuthenticationProvider { + + private final JwtProperties jwtProperties; + + @Override + public Authentication authenticate(Authentication auth) throws AuthenticationException { + JwtAuthentication jwtAuth = (JwtAuthentication) auth; + String token = jwtAuth.getToken(); + String subject = parseSubjectIgnoringExpiration(token, jwtProperties.secret()); + + return new ExpiredTokenAuthentication(token, subject); + } + + @Override + public boolean supports(Class authentication) { + return ExpiredTokenAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java b/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java new file mode 100644 index 000000000..25f211710 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.custom.security.provider; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetailsService; +import com.example.solidconnection.custom.security.authentication.JwtAuthentication; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +@RequiredArgsConstructor +public class SiteUserAuthenticationProvider implements AuthenticationProvider { + + private final JwtProperties jwtProperties; + private final SiteUserDetailsService siteUserDetailsService; + + @Override + public Authentication authenticate(Authentication auth) throws AuthenticationException { + JwtAuthentication jwtAuth = (JwtAuthentication) auth; + String token = jwtAuth.getToken(); + + String username = parseSubject(token, jwtProperties.secret()); + SiteUserDetails userDetails = (SiteUserDetails) siteUserDetailsService.loadUserByUsername(username); + return new SiteUserAuthentication(token, userDetails); + } + + @Override + public boolean supports(Class authentication) { + return SiteUserAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java new file mode 100644 index 000000000..3af238f13 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.type.Role; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +public class SecurityRoleMapper { + + private static final String ROLE_PREFIX = "ROLE_"; + + private SecurityRoleMapper() { + } + + public static List mapRoleToAuthorities(Role role) { + return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + role.name())); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java new file mode 100644 index 000000000..008f77ef5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java @@ -0,0 +1,57 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +public class SiteUserDetails implements UserDetails { + + // userDetails 에서 userName 은 사용자 식별자를 의미함 + private final String userName; + + @Getter + private final SiteUser siteUser; + + public SiteUserDetails(SiteUser siteUser) { + this.siteUser = siteUser; + this.userName = String.valueOf(siteUser.getId()); + } + + @Override + public String getUsername() { + return this.userName; + } + + @Override + public Collection getAuthorities() { + return SecurityRoleMapper.mapRoleToAuthorities(siteUser.getRole()); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return false; + } + + @Override + public boolean isAccountNonLocked() { + return false; + } + + @Override + public boolean isCredentialsNonExpired() { + return false; + } + + @Override + public boolean isEnabled() { + return false; + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java new file mode 100644 index 000000000..fd23fa899 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; + +@Service +@RequiredArgsConstructor +public class SiteUserDetailsService implements UserDetailsService { + + private final SiteUserRepository siteUserRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + long siteUserId = getSiteUserId(username); + SiteUser siteUser = getSiteUser(siteUserId); + validateNotQuit(siteUser); + + return new SiteUserDetails(siteUser); + } + + private long getSiteUserId(String username) { + try { + return Long.parseLong(username); + } catch (NumberFormatException e) { + throw new CustomException(INVALID_TOKEN, "인증 정보가 지정된 형식과 일치하지 않습니다."); + } + } + + private SiteUser getSiteUser(long siteUserId) { + return siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED, "인증 정보에 해당하는 사용자를 찾을 수 없습니다.")); + } + + private void validateNotQuit(SiteUser siteUser) { + if (siteUser.getQuitedAt() != null) { + throw new CustomException(AUTHENTICATION_FAILED, "탈퇴한 사용자입니다."); + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java b/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java new file mode 100644 index 000000000..7e5827113 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.custom.validation.annotation; + +import com.example.solidconnection.custom.validation.validator.ValidUniversityChoiceValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidUniversityChoiceValidator.class) +public @interface ValidUniversityChoice { + + String message() default "유효하지 않은 지망 대학 선택입니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java b/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java new file mode 100644 index 000000000..6ac9fe1c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.custom.validation.validator; + +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.custom.validation.annotation.ValidUniversityChoice; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_UNIVERSITY_CHOICE; +import static com.example.solidconnection.custom.exception.ErrorCode.FIRST_CHOICE_REQUIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; + +public class ValidUniversityChoiceValidator implements ConstraintValidator { + + @Override + public boolean isValid(UniversityChoiceRequest request, ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + + if (isFirstChoiceNotSelected(request)) { + context.buildConstraintViolationWithTemplate(FIRST_CHOICE_REQUIRED.getMessage()) + .addConstraintViolation(); + return false; + } + + if (isThirdChoiceWithoutSecond(request)) { + context.buildConstraintViolationWithTemplate(THIRD_CHOICE_REQUIRES_SECOND.getMessage()) + .addConstraintViolation(); + return false; + } + + if (isDuplicate(request)) { + context.buildConstraintViolationWithTemplate(DUPLICATE_UNIVERSITY_CHOICE.getMessage()) + .addConstraintViolation(); + return false; + } + + return true; + } + + private boolean isFirstChoiceNotSelected(UniversityChoiceRequest request) { + return request.firstChoiceUniversityId() == null; + } + + private boolean isThirdChoiceWithoutSecond(UniversityChoiceRequest request) { + return request.thirdChoiceUniversityId() != null && request.secondChoiceUniversityId() == null; + } + + private boolean isDuplicate(UniversityChoiceRequest request) { + Set uniqueIds = new HashSet<>(); + return Stream.of( + request.firstChoiceUniversityId(), + request.secondChoiceUniversityId(), + request.thirdChoiceUniversityId() + ) + .filter(Objects::nonNull) + .anyMatch(id -> !uniqueIds.add(id)); + } +} diff --git a/src/main/java/com/example/solidconnection/entity/Country.java b/src/main/java/com/example/solidconnection/entity/Country.java new file mode 100644 index 000000000..0a5d974d7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/Country.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode(of = {"code", "koreanName"}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Country { + + @Id + @Column(length = 2) + private String code; + + @Column(nullable = false, length = 100) + private String koreanName; + + @ManyToOne + private Region region; + + public Country(String code, String koreanName, Region region) { + this.code = code; + this.koreanName = koreanName; + this.region = region; + } +} diff --git a/src/main/java/com/example/solidconnection/entity/InterestedCountry.java b/src/main/java/com/example/solidconnection/entity/InterestedCountry.java new file mode 100644 index 000000000..8b8b4e735 --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/InterestedCountry.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.entity; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class InterestedCountry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private SiteUser siteUser; + + @ManyToOne + private Country country; + + public InterestedCountry(SiteUser siteUser, Country country) { + this.siteUser = siteUser; + this.country = country; + } +} diff --git a/src/main/java/com/example/solidconnection/entity/InterestedRegion.java b/src/main/java/com/example/solidconnection/entity/InterestedRegion.java new file mode 100644 index 000000000..7ec8fa50c --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/InterestedRegion.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.entity; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class InterestedRegion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private SiteUser siteUser; + + @ManyToOne + private Region region; + + public InterestedRegion(SiteUser siteUser, Region region) { + this.siteUser = siteUser; + this.region = region; + } +} diff --git a/src/main/java/com/example/solidconnection/entity/Region.java b/src/main/java/com/example/solidconnection/entity/Region.java new file mode 100644 index 000000000..6bd64c5cc --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/Region.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode(of = {"code", "koreanName"}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Region { + + @Id + @Column(length = 10) + private String code; + + @Column(nullable = false, length = 100) + private String koreanName; + + public Region(String code, String koreanName) { + this.code = code; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java b/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java new file mode 100644 index 000000000..27493f1be --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.entity.common; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.ZoneId; +import java.time.ZonedDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +@DynamicUpdate +@DynamicInsert +public abstract class BaseEntity { + + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + + @PrePersist + public void onPrePersist() { + this.createdAt = ZonedDateTime.now(ZoneId.of("UTC")); + this.updatedAt = this.createdAt; + } + + @PreUpdate + public void onPreUpdate() { + this.updatedAt = ZonedDateTime.now(ZoneId.of("UTC")); + } +} diff --git a/src/main/java/com/example/solidconnection/repositories/CountryRepository.java b/src/main/java/com/example/solidconnection/repositories/CountryRepository.java new file mode 100644 index 000000000..d9ba75555 --- /dev/null +++ b/src/main/java/com/example/solidconnection/repositories/CountryRepository.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.repositories; + +import com.example.solidconnection.entity.Country; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CountryRepository extends JpaRepository { + + @Query("SELECT c FROM Country c WHERE c.koreanName IN :names") + List findByKoreanNames(@Param(value = "names") List names); +} diff --git a/src/main/java/com/example/solidconnection/repositories/InterestedCountyRepository.java b/src/main/java/com/example/solidconnection/repositories/InterestedCountyRepository.java new file mode 100644 index 000000000..68e10b320 --- /dev/null +++ b/src/main/java/com/example/solidconnection/repositories/InterestedCountyRepository.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.repositories; + +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface InterestedCountyRepository extends JpaRepository { + List findAllBySiteUser(SiteUser siteUser); +} diff --git a/src/main/java/com/example/solidconnection/repositories/InterestedRegionRepository.java b/src/main/java/com/example/solidconnection/repositories/InterestedRegionRepository.java new file mode 100644 index 000000000..df5acd696 --- /dev/null +++ b/src/main/java/com/example/solidconnection/repositories/InterestedRegionRepository.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.repositories; + +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface InterestedRegionRepository extends JpaRepository { + List findAllBySiteUser(SiteUser siteUser); +} diff --git a/src/main/java/com/example/solidconnection/repositories/RegionRepository.java b/src/main/java/com/example/solidconnection/repositories/RegionRepository.java new file mode 100644 index 000000000..0dc99fb08 --- /dev/null +++ b/src/main/java/com/example/solidconnection/repositories/RegionRepository.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.repositories; + +import com.example.solidconnection.entity.Region; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RegionRepository extends JpaRepository { + + @Query("SELECT r FROM Region r WHERE r.koreanName IN :names") + List findByKoreanNames(@Param(value = "names") List names); +} diff --git a/src/main/java/com/example/solidconnection/s3/AmazonS3Config.java b/src/main/java/com/example/solidconnection/s3/AmazonS3Config.java new file mode 100644 index 000000000..c12f067dd --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/AmazonS3Config.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AmazonS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder + .standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/example/solidconnection/s3/FileUploadService.java b/src/main/java/com/example/solidconnection/s3/FileUploadService.java new file mode 100644 index 000000000..71d9f9c7a --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/FileUploadService.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.s3; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.example.solidconnection.custom.exception.CustomException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.S3_CLIENT_EXCEPTION; +import static com.example.solidconnection.custom.exception.ErrorCode.S3_SERVICE_EXCEPTION; + +@Component +@EnableAsync +@Slf4j +public class FileUploadService { + + private final AmazonS3Client amazonS3; + + public FileUploadService(AmazonS3Client amazonS3) { + this.amazonS3 = amazonS3; + } + + @Async + public void uploadFile(String bucket, String fileName, MultipartFile multipartFile) { + // 메타데이터 생성 + String contentType = multipartFile.getContentType(); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(contentType); + metadata.setContentLength(multipartFile.getSize()); + + try { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, multipartFile.getInputStream(), metadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + log.info("이미지 업로드 정상적 완료 thread: {}", Thread.currentThread().getName()); + } catch (AmazonServiceException e) { + log.error("이미지 업로드 중 s3 서비스 예외 발생 : {}", e.getMessage()); + throw new CustomException(S3_SERVICE_EXCEPTION); + } catch (SdkClientException | IOException e) { + log.error("이미지 업로드 중 s3 클라이언트 예외 발생 : {}", e.getMessage()); + throw new CustomException(S3_CLIENT_EXCEPTION); + } + } +} diff --git a/src/main/java/com/example/solidconnection/s3/S3Controller.java b/src/main/java/com/example/solidconnection/s3/S3Controller.java new file mode 100644 index 000000000..26f9160c0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/S3Controller.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.s3; + +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.ImgType; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.multipart.MultipartFile; + +@RequiredArgsConstructor +@RequestMapping("/file") +@RestController +public class S3Controller { + + private final S3Service s3Service; + + @Value("${cloud.aws.s3.url.default}") + private String s3Default; + + @Value("${cloud.aws.s3.url.uploaded}") + private String s3Uploaded; + + @Value("${cloud.aws.cloudFront.url.default}") + private String cloudFrontDefault; + + @Value("${cloud.aws.cloudFront.url.uploaded}") + private String cloudFrontUploaded; + + @PostMapping("/profile/pre") + public ResponseEntity uploadPreProfileImage( + @RequestParam("file") MultipartFile imageFile + ) { + UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE); + return ResponseEntity.ok(profileImageUrl); + } + + @PostMapping("/profile/post") + public ResponseEntity uploadPostProfileImage( + @AuthorizedUser SiteUser siteUser, + @RequestParam("file") MultipartFile imageFile + ) { + UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE); + s3Service.deleteExProfile(siteUser); + return ResponseEntity.ok(profileImageUrl); + } + + @PostMapping("/gpa") + public ResponseEntity uploadGpaImage( + @RequestParam("file") MultipartFile imageFile + ) { + UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.GPA); + return ResponseEntity.ok(profileImageUrl); + } + + @PostMapping("/language-test") + public ResponseEntity uploadLanguageImage( + @RequestParam("file") MultipartFile imageFile + ) { + UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.LANGUAGE_TEST); + return ResponseEntity.ok(profileImageUrl); + } + + @GetMapping("/s3-url-prefix") + public ResponseEntity getS3UrlPrefix() { + return ResponseEntity.ok(new urlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded)); + } +} diff --git a/src/main/java/com/example/solidconnection/s3/S3Service.java b/src/main/java/com/example/solidconnection/s3/S3Service.java new file mode 100644 index 000000000..2f3c633dd --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/S3Service.java @@ -0,0 +1,132 @@ +package com.example.solidconnection.s3; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.ImgType; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import static com.example.solidconnection.custom.exception.ErrorCode.FILE_NOT_EXIST; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_FILE_EXTENSIONS; +import static com.example.solidconnection.custom.exception.ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS; +import static com.example.solidconnection.custom.exception.ErrorCode.S3_CLIENT_EXCEPTION; +import static com.example.solidconnection.custom.exception.ErrorCode.S3_SERVICE_EXCEPTION; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private static final Logger log = LoggerFactory.getLogger(S3Service.class); + private static final long MAX_FILE_SIZE_MB = 1024 * 1024 * 3; + + private final AmazonS3Client amazonS3; + private final SiteUserRepository siteUserRepository; + private final FileUploadService fileUploadService; + private final ThreadPoolTaskExecutor asyncExecutor; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + /* + * 파일을 S3에 업로드한다. + * - 파일이 존재하는지 검증한다. + * - 파일 확장자가 허용된 확장자인지 검증한다. + * - 파일에 대한 메타 데이터를 생성한다. + * - 임의의 랜덤한 문자열로 파일 이름을 생성한다. + * - S3에 파일을 업로드한다. + * - 3mb 이상의 파일은 /origin/ 경로로 업로드하여 lambda 함수로 리사이징 진행한다. + * - 3mb 미만의 파일은 바로 업로드한다. + * */ + public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, ImgType imageFile) { + // 파일 검증 + validateImgFile(multipartFile); + // 파일 이름 생성 + UUID randomUUID = UUID.randomUUID(); + String fileName = imageFile.getType() + "/" + randomUUID; + // 파일업로드 비동기로 진행 + if (multipartFile.getSize() >= MAX_FILE_SIZE_MB) { + asyncExecutor.submit(() -> { + fileUploadService.uploadFile(bucket, "origin/" + fileName, multipartFile); + }); + } else { + asyncExecutor.submit(() -> { + fileUploadService.uploadFile(bucket, fileName, multipartFile); + }); + } + return new UploadedFileUrlResponse(fileName); + } + + public List uploadFiles(List multipartFile, ImgType imageFile) { + + List uploadedFileUrlResponseList = new ArrayList<>(); + for (MultipartFile file : multipartFile) { + UploadedFileUrlResponse uploadedFileUrlResponse = uploadFile(file, imageFile); + uploadedFileUrlResponseList.add(uploadedFileUrlResponse); + } + return uploadedFileUrlResponseList; + } + + private void validateImgFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new CustomException(FILE_NOT_EXIST); + } + + String fileName = Objects.requireNonNull(file.getOriginalFilename()); + String fileExtension = getFileExtension(fileName).toLowerCase(); + + List allowedExtensions = Arrays.asList("jpg", "jpeg", "png", "webp", "pdf", "word", "docx"); + if (!allowedExtensions.contains(fileExtension)) { + throw new CustomException(NOT_ALLOWED_FILE_EXTENSIONS, "허용된 형식: " + allowedExtensions); + } + } + + private String getFileExtension(String fileName) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == fileName.length() - 1) { + throw new CustomException(INVALID_FILE_EXTENSIONS); + } + return fileName.substring(dotIndex + 1); + } + + /* + * 기존 파일을 삭제한다. + * - 기존 파일의 key(S3파일명)를 찾는다. + * - S3에서 파일을 삭제한다. + * */ + public void deleteExProfile(SiteUser siteUser) { + String key = siteUser.getProfileImageUrl(); + deleteFile(key); + } + + public void deletePostImage(String url) { + deleteFile(url); + } + + private void deleteFile(String fileName) { + try { + amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); + } catch (AmazonServiceException e) { + log.error("파일 삭제 중 s3 서비스 예외 발생 : {}", e.getMessage()); + throw new CustomException(S3_SERVICE_EXCEPTION); + } catch (SdkClientException e) { + log.error("파일 삭제 중 s3 클라이언트 예외 발생 : {}", e.getMessage()); + throw new CustomException(S3_CLIENT_EXCEPTION); + } + } +} diff --git a/src/main/java/com/example/solidconnection/s3/UploadedFileUrlResponse.java b/src/main/java/com/example/solidconnection/s3/UploadedFileUrlResponse.java new file mode 100644 index 000000000..6d9b690fa --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/UploadedFileUrlResponse.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.s3; + +public record UploadedFileUrlResponse( + String fileUrl) { +} diff --git a/src/main/java/com/example/solidconnection/s3/urlPrefixResponse.java b/src/main/java/com/example/solidconnection/s3/urlPrefixResponse.java new file mode 100644 index 000000000..59eac23ca --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/urlPrefixResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.s3; + +public record urlPrefixResponse( + String s3Default, + String s3Uploaded, + String cloudFrontDefault, + String cloudFrontUploaded +) { +} diff --git a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java new file mode 100644 index 000000000..8da1fe1ca --- /dev/null +++ b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.scheduler; + +import com.example.solidconnection.service.UpdateViewCountService; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_KEY_PATTERN; + +@RequiredArgsConstructor +@Component +@EnableScheduling +@EnableAsync +@Slf4j +public class UpdateViewCountScheduler { + + private final RedisUtils redisUtils; + private final ThreadPoolTaskExecutor asyncExecutor; + private final UpdateViewCountService updateViewCountService; + + @Async + @Scheduled(fixedDelayString = "${view.count.scheduling.delay}") + public void updateViewCount() { + + log.info("updateViewCount thread: {}", Thread.currentThread().getName()); + List itemViewCountKeys = redisUtils.getKeysOrderByExpiration(VIEW_COUNT_KEY_PATTERN.getValue()); + + itemViewCountKeys.forEach(key -> asyncExecutor.submit(() -> { + updateViewCountService.updateViewCount(key); + })); + } +} diff --git a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java new file mode 100644 index 000000000..0af509833 --- /dev/null +++ b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.scheduler; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class UserRemovalScheduler { + + public static final String EVERY_MIDNIGHT = "0 0 0 * * ?"; + public static final int ACCOUNT_RECOVER_DURATION = 30; + + private final SiteUserRepository siteUserRepository; + + /* + * 탈퇴 후 계정 복구 기한까지 방문하지 않은 사용자를 삭제한다. + * */ + @Scheduled(cron = EVERY_MIDNIGHT) + public void scheduledUserRemoval() { + LocalDate cutoffDate = LocalDate.now().minusDays(ACCOUNT_RECOVER_DURATION); + List usersToRemove = siteUserRepository.findUsersToBeRemoved(cutoffDate); + siteUserRepository.deleteAll(usersToRemove); + } +} diff --git a/src/main/java/com/example/solidconnection/score/controller/ScoreController.java b/src/main/java/com/example/solidconnection/score/controller/ScoreController.java new file mode 100644 index 000000000..4ea560657 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/controller/ScoreController.java @@ -0,0 +1,67 @@ +package com.example.solidconnection.score.controller; + +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.score.dto.GpaScoreRequest; +import com.example.solidconnection.score.dto.GpaScoreStatusResponse; +import com.example.solidconnection.score.dto.LanguageTestScoreRequest; +import com.example.solidconnection.score.dto.LanguageTestScoreStatusResponse; +import com.example.solidconnection.score.service.ScoreService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/scores") +@RequiredArgsConstructor +public class ScoreController { + + private final ScoreService scoreService; + + // 학점을 등록하는 api + @PostMapping("/gpas") + public ResponseEntity submitGpaScore( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestPart("gpaScoreRequest") GpaScoreRequest gpaScoreRequest, + @RequestParam("file") MultipartFile file + ) { + Long id = scoreService.submitGpaScore(siteUser, gpaScoreRequest, file); + return ResponseEntity.ok(id); + } + + // 어학성적을 등록하는 api + @PostMapping("/language-tests") + public ResponseEntity submitLanguageTestScore( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestPart("languageTestScoreRequest") LanguageTestScoreRequest languageTestScoreRequest, + @RequestParam("file") MultipartFile file + ) { + Long id = scoreService.submitLanguageTestScore(siteUser, languageTestScoreRequest, file); + return ResponseEntity.ok(id); + } + + // 학점 상태를 확인하는 api + @GetMapping("/gpas") + public ResponseEntity getGpaScoreStatus( + @AuthorizedUser SiteUser siteUser + ) { + GpaScoreStatusResponse gpaScoreStatus = scoreService.getGpaScoreStatus(siteUser); + return ResponseEntity.ok(gpaScoreStatus); + } + + // 어학 성적 상태를 확인하는 api + @GetMapping("/language-tests") + public ResponseEntity getLanguageTestScoreStatus( + @AuthorizedUser SiteUser siteUser + ) { + LanguageTestScoreStatusResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(siteUser); + return ResponseEntity.ok(languageTestScoreStatus); + } +} diff --git a/src/main/java/com/example/solidconnection/score/domain/GpaScore.java b/src/main/java/com/example/solidconnection/score/domain/GpaScore.java new file mode 100644 index 000000000..54df13759 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/domain/GpaScore.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.score.domain; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.VerifyStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Entity +@NoArgsConstructor +@EqualsAndHashCode +public class GpaScore extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private Gpa gpa; + + @Setter + @Column(columnDefinition = "varchar(50) not null default 'PENDING'") + @Enumerated(EnumType.STRING) + private VerifyStatus verifyStatus; + + private String rejectedReason; + + @ManyToOne + private SiteUser siteUser; + + public GpaScore(Gpa gpa, SiteUser siteUser) { + this.gpa = gpa; + this.siteUser = siteUser; + this.verifyStatus = VerifyStatus.PENDING; + this.rejectedReason = null; + } + + public void setSiteUser(SiteUser siteUser) { + if (this.siteUser != null) { + this.siteUser.getGpaScoreList().remove(this); + } + this.siteUser = siteUser; + siteUser.getGpaScoreList().add(this); + } +} diff --git a/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java b/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java new file mode 100644 index 000000000..7939e1db8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java @@ -0,0 +1,57 @@ +package com.example.solidconnection.score.domain; + +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.VerifyStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +public class LanguageTestScore extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private LanguageTest languageTest; + + @Setter + @Column(columnDefinition = "varchar(50) not null default 'PENDING'") + @Enumerated(EnumType.STRING) + private VerifyStatus verifyStatus; + + private String rejectedReason; + + @ManyToOne + private SiteUser siteUser; + + public LanguageTestScore(LanguageTest languageTest, SiteUser siteUser) { + this.languageTest = languageTest; + this.verifyStatus = VerifyStatus.PENDING; + this.siteUser = siteUser; + } + + public void setSiteUser(SiteUser siteUser) { + if (this.siteUser != null) { + this.siteUser.getLanguageTestScoreList().remove(this); + } + this.siteUser = siteUser; + siteUser.getLanguageTestScoreList().add(this); + } +} diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java new file mode 100644 index 000000000..beafbf2e3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.score.dto; + +import jakarta.validation.constraints.NotNull; + +public record GpaScoreRequest( + @NotNull(message = "학점을 입력해주세요.") + Double gpa, + + @NotNull(message = "학점 기준을 입력해주세요.") + Double gpaCriteria +) { +} diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java new file mode 100644 index 000000000..5798e3cf0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.score.dto; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.type.VerifyStatus; + +public record GpaScoreStatus( + Long id, + Gpa gpa, + VerifyStatus verifyStatus, + String rejectedReason +) { + public static GpaScoreStatus from(GpaScore gpaScore) { + return new GpaScoreStatus( + gpaScore.getId(), + gpaScore.getGpa(), + gpaScore.getVerifyStatus(), + gpaScore.getRejectedReason() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java new file mode 100644 index 000000000..06fdba0d3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.score.dto; + +import java.util.List; + +public record GpaScoreStatusResponse( + List gpaScoreStatusList +) { +} diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java new file mode 100644 index 000000000..de9329898 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.score.dto; + +import com.example.solidconnection.type.LanguageTestType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record LanguageTestScoreRequest( + @NotNull(message = "어학 종류를 입력해주세요.") + LanguageTestType languageTestType, + + @NotBlank(message = "어학 점수를 입력해주세요.") + String languageTestScore +) { +} diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java new file mode 100644 index 000000000..9e5fcae4f --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.score.dto; + +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.type.VerifyStatus; + +public record LanguageTestScoreStatus( + Long id, + LanguageTest languageTest, + VerifyStatus verifyStatus, + String rejectedReason +) { + public static LanguageTestScoreStatus from(LanguageTestScore languageTestScore) { + return new LanguageTestScoreStatus( + languageTestScore.getId(), + languageTestScore.getLanguageTest(), + languageTestScore.getVerifyStatus(), + languageTestScore.getRejectedReason() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java new file mode 100644 index 000000000..e19c0e855 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.score.dto; + +import java.util.List; + +public record LanguageTestScoreStatusResponse( + List languageTestScoreStatusList +) { +} diff --git a/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java b/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java new file mode 100644 index 000000000..e3c26665b --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.score.repository; + +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface GpaScoreRepository extends JpaRepository { + + Optional findGpaScoreBySiteUser(SiteUser siteUser); + + Optional findGpaScoreBySiteUserAndId(SiteUser siteUser, Long id); +} diff --git a/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java b/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java new file mode 100644 index 000000000..8e6ca3967 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.score.repository; + +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.LanguageTestType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface LanguageTestScoreRepository extends JpaRepository { + + Optional findLanguageTestScoreBySiteUserAndLanguageTest_LanguageTestType(SiteUser siteUser, LanguageTestType languageTestType); + + Optional findLanguageTestScoreBySiteUserAndId(SiteUser siteUser, Long id); +} diff --git a/src/main/java/com/example/solidconnection/score/service/ScoreService.java b/src/main/java/com/example/solidconnection/score/service/ScoreService.java new file mode 100644 index 000000000..66592d339 --- /dev/null +++ b/src/main/java/com/example/solidconnection/score/service/ScoreService.java @@ -0,0 +1,102 @@ +package com.example.solidconnection.score.service; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.dto.GpaScoreRequest; +import com.example.solidconnection.score.dto.GpaScoreStatus; +import com.example.solidconnection.score.dto.GpaScoreStatusResponse; +import com.example.solidconnection.score.dto.LanguageTestScoreRequest; +import com.example.solidconnection.score.dto.LanguageTestScoreStatus; +import com.example.solidconnection.score.dto.LanguageTestScoreStatusResponse; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.ImgType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class ScoreService { + + private final GpaScoreRepository gpaScoreRepository; + private final S3Service s3Service; + private final LanguageTestScoreRepository languageTestScoreRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional + public Long submitGpaScore(SiteUser siteUser, GpaScoreRequest gpaScoreRequest, MultipartFile file) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, ImgType.GPA); + Gpa gpa = new Gpa(gpaScoreRequest.gpa(), gpaScoreRequest.gpaCriteria(), uploadedFile.fileUrl()); + + /* + * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, + * siteUser 에 gpaScoreList 를 FetchType.EAGER 로 설정할 것인지, + * gpa 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. + */ + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + GpaScore newGpaScore = new GpaScore(gpa, siteUser1); + newGpaScore.setSiteUser(siteUser1); + GpaScore savedNewGpaScore = gpaScoreRepository.save(newGpaScore); // 저장 후 반환된 객체 + return savedNewGpaScore.getId(); // 저장된 GPA Score의 ID 반환 + } + + @Transactional + public Long submitLanguageTestScore(SiteUser siteUser, LanguageTestScoreRequest languageTestScoreRequest, MultipartFile file) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, ImgType.LANGUAGE_TEST); + LanguageTest languageTest = new LanguageTest(languageTestScoreRequest.languageTestType(), + languageTestScoreRequest.languageTestScore(), uploadedFile.fileUrl()); + + /* + * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, + * siteUser 에 languageTestScoreList 를 FetchType.EAGER 로 설정할 것인지, + * languageTest 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. + */ + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + LanguageTestScore newScore = new LanguageTestScore(languageTest, siteUser1); + newScore.setSiteUser(siteUser1); + LanguageTestScore savedNewScore = languageTestScoreRepository.save(newScore); // 새로 저장한 객체 + return savedNewScore.getId(); // 저장된 객체의 ID 반환 + } + + @Transactional(readOnly = true) + public GpaScoreStatusResponse getGpaScoreStatus(SiteUser siteUser) { + // todo: ditto + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + List gpaScoreStatusList = + Optional.ofNullable(siteUser1.getGpaScoreList()) + .map(scores -> scores.stream() + .map(GpaScoreStatus::from) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()); + return new GpaScoreStatusResponse(gpaScoreStatusList); + } + + @Transactional(readOnly = true) + public LanguageTestScoreStatusResponse getLanguageTestScoreStatus(SiteUser siteUser) { + // todo: ditto + SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + List languageTestScoreStatusList = + Optional.ofNullable(siteUser1.getLanguageTestScoreList()) + .map(scores -> scores.stream() + .map(LanguageTestScoreStatus::from) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()); + return new LanguageTestScoreStatusResponse(languageTestScoreStatusList); + } +} diff --git a/src/main/java/com/example/solidconnection/service/RedisService.java b/src/main/java/com/example/solidconnection/service/RedisService.java new file mode 100644 index 000000000..36be7b66f --- /dev/null +++ b/src/main/java/com/example/solidconnection/service/RedisService.java @@ -0,0 +1,49 @@ +package com.example.solidconnection.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.type.RedisConstants.VALIDATE_VIEW_COUNT_TTL; +import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_TTL; + +@Service +public class RedisService { + + private final RedisTemplate redisTemplate; + private final RedisScript incrViewCountLuaScript; + + @Autowired + public RedisService(RedisTemplate redisTemplate, + @Qualifier("incrViewCountScript") RedisScript incrViewCountLuaScript) { + this.redisTemplate = redisTemplate; + this.incrViewCountLuaScript = incrViewCountLuaScript; + } + + // incr & set ttl -> lua + public void increaseViewCount(String key) { + redisTemplate.execute(incrViewCountLuaScript, Collections.singletonList(key), VIEW_COUNT_TTL.getValue()); + } + + public void deleteKey(String key) { + redisTemplate.opsForValue().getAndDelete(key); + } + + public Long getAndDelete(String key) { + return Long.valueOf(redisTemplate.opsForValue().getAndDelete(key)); + } + + public boolean isPresent(String key) { + return Boolean.TRUE.equals(redisTemplate.opsForValue() + .setIfAbsent(key, "1", Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()), TimeUnit.SECONDS)); + } + + public boolean isKeyExists(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} diff --git a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java new file mode 100644 index 000000000..2b67e25ec --- /dev/null +++ b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.service; + +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@EnableAsync +@Slf4j +public class UpdateViewCountService { + + private final PostRepository postRepository; + private final RedisService redisService; + private final RedisUtils redisUtils; + + @Transactional + @Async + public void updateViewCount(String key) { + log.info("updateViewCount Processing key: {} in thread: {}", key, Thread.currentThread().getName()); + Long postId = redisUtils.getPostIdFromPostViewCountRedisKey(key); + Post post = postRepository.getById(postId); + postRepository.increaseViewCount(postId, redisService.getAndDelete(key)); + log.info("updateViewCount Updated post id: {} with view count from key: {}", postId, key); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java b/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java new file mode 100644 index 000000000..2f43337ed --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.siteuser.controller; + +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.service.SiteUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.multipart.MultipartFile; + +@RequiredArgsConstructor +@RequestMapping("/my") +@RestController +class SiteUserController { + + private final SiteUserService siteUserService; + + @GetMapping + public ResponseEntity getMyPageInfo( + @AuthorizedUser SiteUser siteUser + ) { + MyPageResponse myPageResponse = siteUserService.getMyPageInfo(siteUser); + return ResponseEntity.ok(myPageResponse); + } + + @PatchMapping + public ResponseEntity updateMyPageInfo( + @AuthorizedUser SiteUser siteUser, + @RequestParam("file") MultipartFile imageFile, + @RequestParam("nickname") String nickname + ) { + siteUserService.updateMyPageInfo(siteUser, imageFile, nickname); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java b/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java new file mode 100644 index 000000000..d13462298 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.siteuser.domain; + +public enum AuthType { + + KAKAO, + APPLE, + EMAIL, + ; + + public static boolean isEmail(AuthType authType) { + return EMAIL.equals(authType); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java new file mode 100644 index 000000000..b1cf6c1cc --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -0,0 +1,161 @@ +package com.example.solidconnection.siteuser.domain; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostLike; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@AllArgsConstructor +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_site_user_email_auth_type", + columnNames = {"email", "auth_type"} + ) +}) +public class SiteUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "email", nullable = false, length = 100) + private String email; + + @Column(name = "auth_type", nullable = false, length = 100) + @Enumerated(EnumType.STRING) + private AuthType authType; + + @Setter + @Column(nullable = false, length = 100) + private String nickname; + + @Setter + @Column(length = 500) + private String profileImageUrl; + + @Column(nullable = false, length = 20) + private String birth; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private PreparationStatus preparationStage; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Role role; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Gender gender; + + @Setter + private LocalDateTime nicknameModifiedAt; + + @Setter + private LocalDate quitedAt; + + @Column(nullable = true) + private String password; + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List postList = new ArrayList<>(); + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL) + private List commentList = new ArrayList<>(); + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List postLikeList = new ArrayList<>(); + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List languageTestScoreList = new ArrayList<>(); + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List gpaScoreList = new ArrayList<>(); + + public SiteUser( + String email, + String nickname, + String profileImageUrl, + String birth, + PreparationStatus preparationStage, + Role role, + Gender gender) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.birth = birth; + this.preparationStage = preparationStage; + this.role = role; + this.gender = gender; + this.authType = AuthType.KAKAO; + } + + public SiteUser( + String email, + String nickname, + String profileImageUrl, + String birth, + PreparationStatus preparationStage, + Role role, + Gender gender, + AuthType authType) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.birth = birth; + this.preparationStage = preparationStage; + this.role = role; + this.gender = gender; + this.authType = authType; + } + + // todo: 가입 방법에 따라서 정해진 인자만 받고, 그렇지 않을 경우 예외 발생하도록 수정 필요 + public SiteUser( + String email, + String nickname, + String profileImageUrl, + String birth, + PreparationStatus preparationStage, + Role role, + Gender gender, + AuthType authType, + String password) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.birth = birth; + this.preparationStage = preparationStage; + this.role = role; + this.gender = gender; + this.authType = authType; + this.password = password; + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java new file mode 100644 index 000000000..9af5e6b2d --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.Role; + +public record MyPageResponse( + String nickname, + String profileImageUrl, + Role role, + AuthType authType, + String birth, + String email, + int likedPostCount, + int likedMentorCount, + int likedUniversityCount) { + + public static MyPageResponse of(SiteUser siteUser, int likedUniversityCount) { + return new MyPageResponse( + siteUser.getNickname(), + siteUser.getProfileImageUrl(), + siteUser.getRole(), + siteUser.getAuthType(), + siteUser.getBirth(), + siteUser.getEmail(), + 0, // TODO: 커뮤니티 기능 생기면 업데이트 필요 + 0, // TODO: 멘토 기능 생기면 업데이트 필요 + likedUniversityCount + ); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/MyPageUpdateRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageUpdateRequest.java new file mode 100644 index 000000000..11584d163 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageUpdateRequest.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.constraints.NotBlank; + +public record MyPageUpdateRequest( + @NotBlank(message = "닉네임을 입력해주세요.") + String nickname, + + String profileImageUrl) { + + public static MyPageUpdateRequest from(SiteUser siteUser) { + return new MyPageUpdateRequest( + siteUser.getNickname(), + siteUser.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/MyPageUpdateResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageUpdateResponse.java new file mode 100644 index 000000000..78583405b --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageUpdateResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.SiteUser; + +public record MyPageUpdateResponse( + String nickname, + String profileImageUrl) { + + public static MyPageUpdateResponse from(SiteUser siteUser) { + return new MyPageUpdateResponse( + siteUser.getNickname(), + siteUser.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java new file mode 100644 index 000000000..9b83969b4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.siteuser.dto; + +import jakarta.validation.constraints.NotBlank; + +public record NicknameUpdateRequest( + @NotBlank(message = "닉네임을 입력해주세요.") + String nickname +) { +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java new file mode 100644 index 000000000..a59e71824 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.SiteUser; + +public record NicknameUpdateResponse( + String nickname +) { + public static NicknameUpdateResponse from(SiteUser siteUser) { + return new NicknameUpdateResponse( + siteUser.getNickname() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java new file mode 100644 index 000000000..85d649631 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.SiteUser; + +public record PostFindSiteUserResponse( + Long id, + String nickname, + String profileImageUrl +) { + public static PostFindSiteUserResponse from(SiteUser siteUser) { + return new PostFindSiteUserResponse( + siteUser.getId(), + siteUser.getNickname(), + siteUser.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java new file mode 100644 index 000000000..d806fde20 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.SiteUser; + +public record ProfileImageUpdateResponse( + String profileImageUrl +) { + public static ProfileImageUpdateResponse from(SiteUser siteUser) { + return new ProfileImageUpdateResponse( + siteUser.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java new file mode 100644 index 000000000..d15949723 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikedUniversityRepository extends JpaRepository { + + List findAllBySiteUser_Id(long siteUserId); + + int countBySiteUser_Id(long siteUserId); + + Optional findBySiteUserAndUniversityInfoForApply(SiteUser siteUser, UniversityInfoForApply universityInfoForApply); +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java new file mode 100644 index 000000000..e0617f046 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface SiteUserRepository extends JpaRepository { + + Optional findByEmailAndAuthType(String email, AuthType authType); + + boolean existsByEmailAndAuthType(String email, AuthType authType); + + boolean existsByNickname(String nickname); + + @Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate") + List findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate); +} diff --git a/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java b/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java new file mode 100644 index 000000000..30297283a --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java @@ -0,0 +1,105 @@ +package com.example.solidconnection.siteuser.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.PROFILE_IMAGE_NEEDED; + +@RequiredArgsConstructor +@Service +public class SiteUserService { + + public static final int MIN_DAYS_BETWEEN_NICKNAME_CHANGES = 7; + public static final DateTimeFormatter NICKNAME_LAST_CHANGE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private final SiteUserRepository siteUserRepository; + private final LikedUniversityRepository likedUniversityRepository; + private final S3Service s3Service; + + /* + * 마이페이지 정보를 조회한다. + * */ + @Transactional(readOnly = true) + public MyPageResponse getMyPageInfo(SiteUser siteUser) { + int likedUniversityCount = likedUniversityRepository.countBySiteUser_Id(siteUser.getId()); + return MyPageResponse.of(siteUser, likedUniversityCount); + } + + /* + * 마이페이지 정보를 수정한다. + * */ + @Transactional + public void updateMyPageInfo(SiteUser siteUser, MultipartFile imageFile, String nickname) { + validateNicknameUnique(nickname); + validateNicknameNotChangedRecently(siteUser.getNicknameModifiedAt()); + validateProfileImageNotEmpty(imageFile); + + if (!isDefaultProfileImage(siteUser.getProfileImageUrl())) { + s3Service.deleteExProfile(siteUser); + } + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.PROFILE); + String profileImageUrl = uploadedFile.fileUrl(); + + siteUser.setProfileImageUrl(profileImageUrl); + siteUser.setNickname(nickname); + siteUser.setNicknameModifiedAt(LocalDateTime.now()); + siteUserRepository.save(siteUser); + } + + private void validateNicknameUnique(String nickname) { + if (siteUserRepository.existsByNickname(nickname)) { + throw new CustomException(NICKNAME_ALREADY_EXISTED); + } + } + + private void validateNicknameNotChangedRecently(LocalDateTime lastModifiedAt) { + if (lastModifiedAt == null) { + return; + } + if (LocalDateTime.now().isBefore(lastModifiedAt.plusDays(MIN_DAYS_BETWEEN_NICKNAME_CHANGES))) { + String formatLastModifiedAt + = String.format("(마지막 수정 시간 : %s)", NICKNAME_LAST_CHANGE_DATE_FORMAT.format(lastModifiedAt)); + throw new CustomException(CAN_NOT_CHANGE_NICKNAME_YET, formatLastModifiedAt); + } + } + + private void validateProfileImageNotEmpty(MultipartFile imageFile) { + if (imageFile == null || imageFile.isEmpty()) { + throw new CustomException(PROFILE_IMAGE_NEEDED); + } + } + + private boolean isDefaultProfileImage(String profileImageUrl) { + String prefix = "profile/"; + return profileImageUrl == null || !profileImageUrl.startsWith(prefix); + } + + /* + * 관심 대학교 목록을 조회한다. + * */ + @Transactional(readOnly = true) + public List getWishUniversity(SiteUser siteUser) { + List likedUniversities = likedUniversityRepository.findAllBySiteUser_Id(siteUser.getId()); + return likedUniversities.stream() + .map(likedUniversity -> UniversityInfoForApplyPreviewResponse.from(likedUniversity.getUniversityInfoForApply())) + .toList(); + } +} diff --git a/src/main/java/com/example/solidconnection/type/BoardCode.java b/src/main/java/com/example/solidconnection/type/BoardCode.java new file mode 100644 index 000000000..0d161e941 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/BoardCode.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.type; + +public enum BoardCode { + EUROPE, AMERICAS, ASIA, FREE; +} diff --git a/src/main/java/com/example/solidconnection/type/Gender.java b/src/main/java/com/example/solidconnection/type/Gender.java new file mode 100644 index 000000000..92a78814b --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/Gender.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.type; + +public enum Gender { + MALE, FEMALE, PREFER_NOT_TO_SAY +} diff --git a/src/main/java/com/example/solidconnection/type/ImgType.java b/src/main/java/com/example/solidconnection/type/ImgType.java new file mode 100644 index 000000000..45eb516bb --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/ImgType.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.type; + +import lombok.Getter; + +@Getter +public enum ImgType { + PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"); + + private final String type; + + ImgType(String type) { + this.type = type; + } +} diff --git a/src/main/java/com/example/solidconnection/type/LanguageTestType.java b/src/main/java/com/example/solidconnection/type/LanguageTestType.java new file mode 100644 index 000000000..30249dc82 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/LanguageTestType.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.type; + +import java.util.Comparator; + +public enum LanguageTestType { + + CEFR((s1, s2) -> s1.compareTo(s2)), + JLPT((s1, s2) -> s2.compareTo(s1)), + DALF(LanguageTestType::compareIntegerScores), + DELF(LanguageTestType::compareIntegerScores), + DUOLINGO(LanguageTestType::compareIntegerScores), + IELTS(LanguageTestType::compareDoubleScores), + NEW_HSK(LanguageTestType::compareIntegerScores), + TCF(LanguageTestType::compareIntegerScores), + TEF(LanguageTestType::compareIntegerScores), + TOEFL_IBT(LanguageTestType::compareIntegerScores), + TOEFL_ITP(LanguageTestType::compareIntegerScores), + TOEIC(LanguageTestType::compareIntegerScores); + + private final Comparator comparator; + + LanguageTestType(Comparator comparator) { + this.comparator = comparator; + } + + private static int compareIntegerScores(String s1, String s2) { + return Integer.compare(Integer.parseInt(s1), Integer.parseInt(s2)); + } + + private static int compareDoubleScores(String s1, String s2) { + return Double.compare(Double.parseDouble(s1), Double.parseDouble(s2)); + } + + public int compare(String s1, String s2) { + return comparator.compare(s1, s2); + } +} diff --git a/src/main/java/com/example/solidconnection/type/PostCategory.java b/src/main/java/com/example/solidconnection/type/PostCategory.java new file mode 100644 index 000000000..b42b94f95 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/PostCategory.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.type; + +public enum PostCategory { + 전체, 자유, 질문 +} diff --git a/src/main/java/com/example/solidconnection/type/PreparationStatus.java b/src/main/java/com/example/solidconnection/type/PreparationStatus.java new file mode 100644 index 000000000..c4f1650e9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/PreparationStatus.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.type; + +public enum PreparationStatus { + CONSIDERING, // 교환학생 지원 고민 상태 + PREPARING_FOR_DEPARTURE, // 교환학생 합격 후 파견 준비 상태 + STUDYING_ABROAD, // 해외 학교에서 공부중인 상태 + AFTER_EXCHANGE +} diff --git a/src/main/java/com/example/solidconnection/type/RedisConstants.java b/src/main/java/com/example/solidconnection/type/RedisConstants.java new file mode 100644 index 000000000..7d4c7f2c9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/RedisConstants.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.type; + +import lombok.Getter; + +@Getter +public enum RedisConstants { + VIEW_COUNT_TTL("60"), + VALIDATE_VIEW_COUNT_TTL("1"), + VALIDATE_VIEW_COUNT_KEY_PREFIX("validate:post:view:"), + VIEW_COUNT_KEY_PREFIX("post:view:"), + VIEW_COUNT_KEY_PATTERN("post:view:*"), + + REFRESH_LIMIT_PERCENT("10.0"), + CREATE_LOCK_PREFIX("create_lock:"), + REFRESH_LOCK_PREFIX("refresh_lock:"), + LOCK_TIMEOUT_MS("10000"), + MAX_WAIT_TIME_MS("3000"), + CREATE_CHANNEL("create_channel"); + + private final String value; + + RedisConstants(String value) { + this.value = value; + } +} diff --git a/src/main/java/com/example/solidconnection/type/Role.java b/src/main/java/com/example/solidconnection/type/Role.java new file mode 100644 index 000000000..8223e8de0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/Role.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.type; + +public enum Role { + + ADMIN, + MENTOR, + MENTEE; +} diff --git a/src/main/java/com/example/solidconnection/type/SemesterAvailableForDispatch.java b/src/main/java/com/example/solidconnection/type/SemesterAvailableForDispatch.java new file mode 100644 index 000000000..261db87af --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/SemesterAvailableForDispatch.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.type; + +public enum SemesterAvailableForDispatch { + ONE_SEMESTER("1개학기"), + FOUR_SEMESTER("4개학기"), + ONE_OR_TWO_SEMESTER("1개 또는 2개 학기"), + ONE_YEAR("1년만 가능"), + IRRELEVANT("무관"), + NO_DATA("데이터 없음"), + ; + + private final String koreanName; + + SemesterAvailableForDispatch(String koreanName) { + this.koreanName = koreanName; + } + + public String getKoreanName() { + return koreanName; + } +} diff --git a/src/main/java/com/example/solidconnection/type/TuitionFeeType.java b/src/main/java/com/example/solidconnection/type/TuitionFeeType.java new file mode 100644 index 000000000..21ab6700e --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/TuitionFeeType.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.type; + +public enum TuitionFeeType { + HOME_UNIVERSITY_PAYMENT("본교등록금납부형"), + OVERSEAS_UNIVERSITY_PAYMENT("해외대학등록금납부형"), + MIXED_PAYMENT("혼합형"); + + private final String koreanName; + + TuitionFeeType(String koreanName) { + this.koreanName = koreanName; + } + + public String getKoreanName() { + return koreanName; + } +} diff --git a/src/main/java/com/example/solidconnection/type/VerifyStatus.java b/src/main/java/com/example/solidconnection/type/VerifyStatus.java new file mode 100644 index 000000000..95f122715 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/VerifyStatus.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.type; + +public enum VerifyStatus { + PENDING, REJECTED, APPROVED +} diff --git a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java new file mode 100644 index 000000000..83d90f600 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java @@ -0,0 +1,103 @@ +package com.example.solidconnection.university.controller; + +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.service.SiteUserService; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.LikeResultResponse; +import com.example.solidconnection.university.dto.UniversityDetailResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +import com.example.solidconnection.university.service.UniversityLikeService; +import com.example.solidconnection.university.service.UniversityQueryService; +import com.example.solidconnection.university.service.UniversityRecommendService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/universities") +@RestController +public class UniversityController { + + private final UniversityQueryService universityQueryService; + private final UniversityLikeService universityLikeService; + private final UniversityRecommendService universityRecommendService; + private final SiteUserService siteUserService; + + @GetMapping("/recommend") + public ResponseEntity getUniversityRecommends( + @AuthorizedUser(required = false) SiteUser siteUser + ) { + if (siteUser == null) { + return ResponseEntity.ok(universityRecommendService.getGeneralRecommends()); + } else { + return ResponseEntity.ok(universityRecommendService.getPersonalRecommends(siteUser)); + } + } + + @GetMapping("/like") + public ResponseEntity> getMyWishUniversity( + @AuthorizedUser SiteUser siteUser + ) { + List wishUniversities = siteUserService.getWishUniversity(siteUser); + return ResponseEntity.ok(wishUniversities); + } + + @GetMapping("/{universityInfoForApplyId}/like") + public ResponseEntity getIsLiked( + @AuthorizedUser SiteUser siteUser, + @PathVariable Long universityInfoForApplyId + ) { + IsLikeResponse isLiked = universityLikeService.getIsLiked(siteUser, universityInfoForApplyId); + return ResponseEntity.ok(isLiked); + } + + @PostMapping("/{universityInfoForApplyId}/like") + public ResponseEntity addWishUniversity( + @AuthorizedUser SiteUser siteUser, + @PathVariable Long universityInfoForApplyId + ) { + LikeResultResponse likeResultResponse = universityLikeService.likeUniversity(siteUser, universityInfoForApplyId); + return ResponseEntity.ok(likeResultResponse); + } + + @DeleteMapping("/{universityInfoForApplyId}/like") + public ResponseEntity cancelWishUniversity( + @AuthorizedUser SiteUser siteUser, + @PathVariable Long universityInfoForApplyId + ) { + LikeResultResponse likeResultResponse = universityLikeService.cancelLikeUniversity(siteUser, universityInfoForApplyId); + return ResponseEntity.ok(likeResultResponse); + } + + @GetMapping("/{universityInfoForApplyId}") + public ResponseEntity getUniversityDetails( + @PathVariable Long universityInfoForApplyId + ) { + UniversityDetailResponse universityDetailResponse = universityQueryService.getUniversityDetail(universityInfoForApplyId); + return ResponseEntity.ok(universityDetailResponse); + } + + // todo return타입 UniversityInfoForApplyPreviewResponses로 추후 수정 필요 + @GetMapping("/search") + public ResponseEntity> searchUniversity( + @RequestParam(required = false, defaultValue = "") String region, + @RequestParam(required = false, defaultValue = "") List keyword, + @RequestParam(required = false, defaultValue = "") LanguageTestType testType, + @RequestParam(required = false, defaultValue = "") String testScore + ) { + List universityInfoForApplyPreviewResponse + = universityQueryService.searchUniversity(region, keyword, testType, testScore).universityInfoForApplyPreviewResponses(); + return ResponseEntity.ok(universityInfoForApplyPreviewResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java b/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java new file mode 100644 index 000000000..508c7531e --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.university.domain; + +import com.example.solidconnection.type.LanguageTestType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor(access = AccessLevel.PUBLIC) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class LanguageRequirement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private LanguageTestType languageTestType; + + @Column(nullable = false) + private String minScore; + + @ManyToOne(fetch = FetchType.LAZY) + private UniversityInfoForApply universityInfoForApply; +} diff --git a/src/main/java/com/example/solidconnection/university/domain/LikedUniversity.java b/src/main/java/com/example/solidconnection/university/domain/LikedUniversity.java new file mode 100644 index 000000000..ad7ee02c8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/LikedUniversity.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.university.domain; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LikedUniversity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private UniversityInfoForApply universityInfoForApply; + + @ManyToOne + private SiteUser siteUser; +} diff --git a/src/main/java/com/example/solidconnection/university/domain/University.java b/src/main/java/com/example/solidconnection/university/domain/University.java new file mode 100644 index 000000000..c3021385e --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/University.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.university.domain; + +import com.example.solidconnection.entity.Country; +import com.example.solidconnection.entity.Region; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@AllArgsConstructor(access = AccessLevel.PUBLIC) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class University { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String koreanName; + + @Column(nullable = false, length = 100) + private String englishName; + + @Column(nullable = false, length = 100) + private String formatName; + + @Column(length = 500) + private String homepageUrl; + + @Column(length = 500) + private String englishCourseUrl; + + @Column(length = 500) + private String accommodationUrl; + + @Column(nullable = false, length = 500) + private String logoImageUrl; + + @Column(nullable = false, length = 500) + private String backgroundImageUrl; + + @Column(length = 1000) + private String detailsForLocal; + + @ManyToOne + private Country country; + + @ManyToOne + private Region region; +} diff --git a/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java b/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java new file mode 100644 index 000000000..e1a87fe83 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.university.domain; + +import com.example.solidconnection.type.SemesterAvailableForDispatch; +import com.example.solidconnection.type.TuitionFeeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; + +@Getter +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = AccessLevel.PUBLIC) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class UniversityInfoForApply { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 50, nullable = false) + private String term; + + @Column(nullable = false, length = 100) + private String koreanName; + + @Column + private Integer studentCapacity; + + @Column + @Enumerated(EnumType.STRING) + private TuitionFeeType tuitionFeeType; + + @Column + @Enumerated(EnumType.STRING) + private SemesterAvailableForDispatch semesterAvailableForDispatch; + + @Column(length = 100) + private String semesterRequirement; + + @Column(length = 1000) + private String detailsForLanguage; + + @Column(length = 100) + private String gpaRequirement; + + @Column(length = 100) + private String gpaRequirementCriteria; + + @Column(length = 1000) + private String detailsForApply; + + @Column(length = 1000) + private String detailsForMajor; + + @Column(length = 1000) + private String detailsForAccommodation; + + @Column(length = 1000) + private String detailsForEnglishCourse; + + @Column(length = 1000) + private String details; + + @OneToMany(mappedBy = "universityInfoForApply", fetch = FetchType.EAGER) + private Set languageRequirements = new HashSet<>(); + + @ManyToOne(fetch = FetchType.EAGER) + private University university; + + public void addLanguageRequirements(LanguageRequirement languageRequirements) { + this.languageRequirements.add(languageRequirements); + } +} diff --git a/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java b/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java new file mode 100644 index 000000000..7d4aebbf9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.university.dto; + +public record IsLikeResponse( + boolean isLike) { +} diff --git a/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java b/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java new file mode 100644 index 000000000..8cc7b9733 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.university.dto; + +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageRequirement; + +public record LanguageRequirementResponse( + LanguageTestType languageTestType, + String minScore) implements Comparable { + + public static LanguageRequirementResponse from(LanguageRequirement languageRequirement) { + return new LanguageRequirementResponse( + languageRequirement.getLanguageTestType(), + languageRequirement.getMinScore()); + } + + @Override + public int compareTo(LanguageRequirementResponse other) { + return this.languageTestType.name().compareTo(other.languageTestType.name()); + } +} diff --git a/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java b/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java new file mode 100644 index 000000000..c67f2e408 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.university.dto; + +public record LikeResultResponse( + String result) { +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityDetailResponse.java b/src/main/java/com/example/solidconnection/university/dto/UniversityDetailResponse.java new file mode 100644 index 000000000..4121654a3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UniversityDetailResponse.java @@ -0,0 +1,70 @@ +package com.example.solidconnection.university.dto; + +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; + +import java.util.List; + +public record UniversityDetailResponse( + long id, + String term, + String koreanName, + String englishName, + String formatName, + String region, + String country, + String homepageUrl, + String logoImageUrl, + String backgroundImageUrl, + String detailsForLocal, + int studentCapacity, + String tuitionFeeType, + String semesterAvailableForDispatch, + List languageRequirements, + String detailsForLanguage, + String gpaRequirement, + String gpaRequirementCriteria, + String semesterRequirement, + String detailsForApply, + String detailsForMajor, + String detailsForAccommodation, + String detailsForEnglishCourse, + String details, + String accommodationUrl, + String englishCourseUrl) { + + public static UniversityDetailResponse of( + University university, + UniversityInfoForApply universityInfoForApply) { + return new UniversityDetailResponse( + universityInfoForApply.getId(), + universityInfoForApply.getTerm(), + universityInfoForApply.getKoreanName(), + university.getEnglishName(), + university.getFormatName(), + university.getRegion().getKoreanName(), + university.getCountry().getKoreanName(), + university.getHomepageUrl(), + university.getLogoImageUrl(), + university.getBackgroundImageUrl(), + university.getDetailsForLocal(), + universityInfoForApply.getStudentCapacity(), + universityInfoForApply.getTuitionFeeType().getKoreanName(), + universityInfoForApply.getSemesterAvailableForDispatch().getKoreanName(), + universityInfoForApply.getLanguageRequirements().stream() + .map(LanguageRequirementResponse::from) + .toList(), + universityInfoForApply.getDetailsForLanguage(), + universityInfoForApply.getGpaRequirement(), + universityInfoForApply.getGpaRequirementCriteria(), + universityInfoForApply.getSemesterRequirement(), + universityInfoForApply.getDetailsForApply(), + universityInfoForApply.getDetailsForMajor(), + universityInfoForApply.getDetailsForAccommodation(), + universityInfoForApply.getDetailsForEnglishCourse(), + universityInfoForApply.getDetails(), + university.getAccommodationUrl(), + university.getEnglishCourseUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java new file mode 100644 index 000000000..f6c2b4969 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.university.dto; + +import com.example.solidconnection.university.domain.UniversityInfoForApply; + +import java.util.Collections; +import java.util.List; + +public record UniversityInfoForApplyPreviewResponse( + long id, + String term, + String koreanName, + String region, + String country, + String logoImageUrl, + String backgroundImageUrl, + int studentCapacity, + List languageRequirements) { + + public static UniversityInfoForApplyPreviewResponse from(UniversityInfoForApply universityInfoForApply) { + List languageRequirementResponses = new java.util.ArrayList<>( + universityInfoForApply.getLanguageRequirements().stream() + .map(LanguageRequirementResponse::from) + .toList()); + Collections.sort(languageRequirementResponses); + + return new UniversityInfoForApplyPreviewResponse( + universityInfoForApply.getId(), + universityInfoForApply.getTerm(), + universityInfoForApply.getKoreanName(), + universityInfoForApply.getUniversity().getRegion().getKoreanName(), + universityInfoForApply.getUniversity().getCountry().getKoreanName(), + universityInfoForApply.getUniversity().getLogoImageUrl(), + universityInfoForApply.getUniversity().getBackgroundImageUrl(), + universityInfoForApply.getStudentCapacity(), + languageRequirementResponses + ); + } +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java new file mode 100644 index 000000000..3c8a00df4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.university.dto; + +import java.util.List; + +public record UniversityInfoForApplyPreviewResponses( + List universityInfoForApplyPreviewResponses +) { +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityRecommendsResponse.java b/src/main/java/com/example/solidconnection/university/dto/UniversityRecommendsResponse.java new file mode 100644 index 000000000..057061f3e --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UniversityRecommendsResponse.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.university.dto; + +import java.util.List; + +public record UniversityRecommendsResponse( + List recommendedUniversities) { +} diff --git a/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java b/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java new file mode 100644 index 000000000..4cbebc6f5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.university.repository; + +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface LanguageRequirementRepository extends JpaRepository { + + @Query("SELECT lr FROM LanguageRequirement lr WHERE lr.minScore <= :myScore AND lr.languageTestType = :testType AND lr.universityInfoForApply = :universityInfoForApply ORDER BY lr.minScore ASC") + Optional findByUniversityInfoForApplyAndLanguageTestTypeAndLessThanMyScore(@Param("universityInfoForApply") UniversityInfoForApply universityInfoForApply, @Param("testType") LanguageTestType testType, @Param("myScore") String myScore); +} diff --git a/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java b/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java new file mode 100644 index 000000000..60474c13d --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java @@ -0,0 +1,65 @@ +package com.example.solidconnection.university.repository; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM; + +@Repository +public interface UniversityInfoForApplyRepository extends JpaRepository { + + Optional findByIdAndTerm(Long id, String term); + + Optional findFirstByKoreanNameAndTerm(String koreanName, String term); + + @Query("SELECT c FROM UniversityInfoForApply c WHERE c.university IN :universities AND c.term = :term") + List findByUniversitiesAndTerm(@Param("universities") List universities, @Param("term") String term); + + @Query(""" + SELECT uifa + FROM UniversityInfoForApply uifa + JOIN University u ON uifa.university = u + WHERE (u.country.code IN ( + SELECT c.code + FROM InterestedCountry ic + JOIN ic.country c + WHERE ic.siteUser = :siteUser + ) + OR u.region.code IN ( + SELECT r.code + FROM InterestedRegion ir + JOIN ir.region r + WHERE ir.siteUser = :siteUser + )) + AND uifa.term = :term + """) + List findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(@Param("siteUser") SiteUser siteUser, @Param("term") String term); + + @Query(value = """ + SELECT * + FROM university_info_for_apply + WHERE term = :term + ORDER BY RAND() LIMIT :limitNum + """, nativeQuery = true) + List findRandomByTerm(@Param("term") String term, @Param("limitNum") int limitNum); + + default UniversityInfoForApply getUniversityInfoForApplyById(Long id) { + return findById(id) + .orElseThrow(() -> new CustomException(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND)); + } + + default UniversityInfoForApply getUniversityInfoForApplyByIdAndTerm(Long id, String term) { + return findByIdAndTerm(id, term) + .orElseThrow(() -> new CustomException(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM)); + } +} diff --git a/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java b/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java new file mode 100644 index 000000000..e4cdeade2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.university.repository; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.custom.UniversityFilterRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_NOT_FOUND; + +@Repository +public interface UniversityRepository extends JpaRepository, UniversityFilterRepository { + + @Query("SELECT u FROM University u WHERE u.country.code IN :countryCodes OR u.region.code IN :regionCodes") + List findByCountryCodeInOrRegionCodeIn(@Param("countryCodes") List countryCodes, @Param("regionCodes") List regionCodes); + + default University getUniversityById(Long id) { + return findById(id) + .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java new file mode 100644 index 000000000..009496be7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.university.repository.custom; + +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; + +import java.util.List; + +public interface UniversityFilterRepository { + + List findByRegionCodeAndKeywords(String regionCode, List keywords); + + List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( + String regionCode, List keywords, LanguageTestType testType, String testScore, String term); +} diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java new file mode 100644 index 000000000..dd84cfbf5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java @@ -0,0 +1,110 @@ +package com.example.solidconnection.university.repository.custom; + +import com.example.solidconnection.entity.QCountry; +import com.example.solidconnection.entity.QRegion; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.QUniversity; +import com.example.solidconnection.university.domain.QUniversityInfoForApply; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class UniversityFilterRepositoryImpl implements UniversityFilterRepository { + + private final JPAQueryFactory queryFactory; + + @Autowired + public UniversityFilterRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public List findByRegionCodeAndKeywords(String regionCode, List keywords) { + QUniversity university = QUniversity.university; + QCountry country = QCountry.country; + QRegion region = QRegion.region; + + return queryFactory + .selectFrom(university) + .join(university.country, country) + .join(country.region, region) + .where(regionCodeEq(region, regionCode) + .and(countryOrUniversityContainsKeyword(country, university, keywords)) + ) + .fetch(); + } + + private BooleanExpression regionCodeEq(QRegion region, String regionCode) { + if (regionCode == null || regionCode.isEmpty()) { + return Expressions.asBoolean(true).isTrue(); + } + return region.code.eq(regionCode); + } + + private BooleanExpression countryOrUniversityContainsKeyword(QCountry country, QUniversity university, List keywords) { + if (keywords == null || keywords.isEmpty()) { + return Expressions.TRUE; + } + BooleanExpression countryCondition = createKeywordCondition(country.koreanName, keywords); + BooleanExpression universityCondition = createKeywordCondition(university.koreanName, keywords); + return countryCondition.or(universityCondition); + } + + private BooleanExpression createKeywordCondition(StringPath namePath, List keywords) { + return keywords.stream() + .map(namePath::contains) + .reduce(BooleanExpression::or) + .orElse(Expressions.FALSE); + } + + @Override + public List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( + String regionCode, List keywords, LanguageTestType testType, String testScore, String term) { + + QUniversity university = QUniversity.university; + QCountry country = QCountry.country; + QRegion region = QRegion.region; + QUniversityInfoForApply universityInfoForApply = QUniversityInfoForApply.universityInfoForApply; + + List filteredUniversityInfoForApply = queryFactory + .selectFrom(universityInfoForApply) + .join(universityInfoForApply.university, university) + .join(university.country, country) + .join(university.region, region) + .where(regionCodeEq(region, regionCode) + .and(countryOrUniversityContainsKeyword(country, university, keywords)) + .and(universityInfoForApply.term.eq(term))) + .fetch(); + + if (testScore == null || testScore.isEmpty()) { + if (testType != null) { + return filteredUniversityInfoForApply.stream() + .filter(uifa -> uifa.getLanguageRequirements().stream() + .anyMatch(lr -> lr.getLanguageTestType().equals(testType))) + .toList(); + } + return filteredUniversityInfoForApply; + } + + return filteredUniversityInfoForApply.stream() + .filter(uifa -> compareMyTestScoreToMinPassScore(uifa, testType, testScore) >= 0) + .toList(); + } + + private int compareMyTestScoreToMinPassScore(UniversityInfoForApply universityInfoForApply, LanguageTestType testType, String testScore) { + return universityInfoForApply.getLanguageRequirements().stream() + .filter(languageRequirement -> languageRequirement.getLanguageTestType().equals(testType)) + .findFirst() + .map(requirement -> testType.compare(testScore, requirement.getMinScore())) + .orElse(-1); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java new file mode 100644 index 000000000..d39fee1ec --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; + +@Service +@RequiredArgsConstructor +public class GeneralUniversityRecommendService { + + /* + * 해당 시기에 열리는 대학교들 중 랜덤으로 선택해서 목록을 구성한다. + * */ + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Getter + private List recommendUniversities; + + @Value("${university.term}") + public String term; + + @EventListener(ApplicationReadyEvent.class) + public void init() { + recommendUniversities = universityInfoForApplyRepository.findRandomByTerm(term, RECOMMEND_UNIVERSITY_NUM); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java b/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java new file mode 100644 index 000000000..85971663b --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java @@ -0,0 +1,79 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.LikeResultResponse; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.ALREADY_LIKED_UNIVERSITY; +import static com.example.solidconnection.custom.exception.ErrorCode.NOT_LIKED_UNIVERSITY; + +@RequiredArgsConstructor +@Service +public class UniversityLikeService { + + public static final String LIKE_SUCCESS_MESSAGE = "LIKE_SUCCESS"; + public static final String LIKE_CANCELED_MESSAGE = "LIKE_CANCELED"; + + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final LikedUniversityRepository likedUniversityRepository; + + @Value("${university.term}") + public String term; + + /* + * 대학교를 '좋아요' 한다. + * */ + @Transactional + public LikeResultResponse likeUniversity(SiteUser siteUser, Long universityInfoForApplyId) { + UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); + + Optional optionalLikedUniversity = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply); + if (optionalLikedUniversity.isPresent()) { + throw new CustomException(ALREADY_LIKED_UNIVERSITY); + } + + LikedUniversity likedUniversity = LikedUniversity.builder() + .universityInfoForApply(universityInfoForApply) + .siteUser(siteUser) + .build(); + likedUniversityRepository.save(likedUniversity); + return new LikeResultResponse(LIKE_SUCCESS_MESSAGE); + } + + /* + * 대학교 '좋아요'를 취소한다. + * */ + @Transactional + public LikeResultResponse cancelLikeUniversity(SiteUser siteUser, long universityInfoForApplyId) throws CustomException { + UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); + + Optional optionalLikedUniversity = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply); + if (optionalLikedUniversity.isEmpty()) { + throw new CustomException(NOT_LIKED_UNIVERSITY); + } + + likedUniversityRepository.delete(optionalLikedUniversity.get()); + return new LikeResultResponse(LIKE_CANCELED_MESSAGE); + } + + /* + * '좋아요'한 대학교인지 확인한다. + * */ + @Transactional(readOnly = true) + public IsLikeResponse getIsLiked(SiteUser siteUser, Long universityInfoForApplyId) { + UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); + boolean isLike = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply).isPresent(); + return new IsLikeResponse(isLike); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java b/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java new file mode 100644 index 000000000..f93f3ffae --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.UniversityDetailResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponses; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.custom.UniversityFilterRepositoryImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class UniversityQueryService { + + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final UniversityFilterRepositoryImpl universityFilterRepository; + + @Value("${university.term}") + public String term; + + /* + * 대학교 상세 정보를 불러온다. + * - 대학교(University) 정보와 대학 지원 정보(UniversityInfoForApply) 정보를 조합하여 반환한다. + * */ + @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "university:{0}", cacheManager = "customCacheManager", ttlSec = 86400) + public UniversityDetailResponse getUniversityDetail(Long universityInfoForApplyId) { + UniversityInfoForApply universityInfoForApply + = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); + University university = universityInfoForApply.getUniversity(); + + return UniversityDetailResponse.of(university, universityInfoForApply); + } + + /* + * 대학교 검색 결과를 불러온다. + * - 권역, 키워드, 언어 시험 종류, 언어 시험 점수를 조건으로 검색하여 결과를 반환한다. + * - 권역은 영어 대문자로 받는다 e.g. ASIA + * - 키워드는 국가명 또는 대학명에 포함되는 것이 조건이다. + * - 언어 시험 점수는 합격 최소 점수보다 높은 것이 조건이다. + * */ + @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "university:{0}:{1}:{2}:{3}", cacheManager = "customCacheManager", ttlSec = 86400) + public UniversityInfoForApplyPreviewResponses searchUniversity( + String regionCode, List keywords, LanguageTestType testType, String testScore) { + + return new UniversityInfoForApplyPreviewResponses(universityFilterRepository + .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm(regionCode, keywords, testType, testScore, term) + .stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java new file mode 100644 index 000000000..4d9ab6242 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@RequiredArgsConstructor +@Service +public class UniversityRecommendService { + + public static final int RECOMMEND_UNIVERSITY_NUM = 6; + + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final GeneralUniversityRecommendService generalUniversityRecommendService; + + @Value("${university.term}") + private String term; + + /* + * 사용자 맞춤 추천 대학교를 불러온다. + * - 회원가입 시 선택한 관심 지역과 관심 국가에 해당하는 대학 중, 이번 term 에 열리는 학교들을 불러온다. + * - 불러온 맞춤 추천 대학교의 순서를 무작위로 섞는다. + * - 맞춤 추천 대학교의 수가 6개보다 적다면, 공통 추천 대학교 후보에서 이번 term 에 열리는 학교들을 부족한 수 만큼 불러온다. + * */ + @Transactional(readOnly = true) + public UniversityRecommendsResponse getPersonalRecommends(SiteUser siteUser) { + // 맞춤 추천 대학교를 불러온다. + List personalRecommends = universityInfoForApplyRepository + .findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(siteUser, term); + List trimmedRecommendUniversities + = personalRecommends.subList(0, Math.min(RECOMMEND_UNIVERSITY_NUM, personalRecommends.size())); + Collections.shuffle(trimmedRecommendUniversities); + + // 맞춤 추천 대학교의 수가 6개보다 적다면, 일반 추천 대학교를 부족한 수 만큼 불러온다. + if (trimmedRecommendUniversities.size() < RECOMMEND_UNIVERSITY_NUM) { + trimmedRecommendUniversities.addAll(getGeneralRecommendsExcludingSelected(trimmedRecommendUniversities)); + } + + return new UniversityRecommendsResponse(trimmedRecommendUniversities.stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList()); + } + + private List getGeneralRecommendsExcludingSelected(List alreadyPicked) { + List generalRecommend = new ArrayList<>(generalUniversityRecommendService.getRecommendUniversities()); + generalRecommend.removeAll(alreadyPicked); + Collections.shuffle(generalRecommend); + return generalRecommend.subList(0, RECOMMEND_UNIVERSITY_NUM - alreadyPicked.size()); + } + + /* + * 공통 추천 대학교를 불러온다. + * */ + @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "university:recommend:general", cacheManager = "customCacheManager", ttlSec = 86400) + public UniversityRecommendsResponse getGeneralRecommends() { + List generalRecommends = new ArrayList<>(generalUniversityRecommendService.getRecommendUniversities()); + return new UniversityRecommendsResponse(generalRecommends.stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/util/JwtUtils.java b/src/main/java/com/example/solidconnection/util/JwtUtils.java new file mode 100644 index 000000000..d3ea8fed9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/util/JwtUtils.java @@ -0,0 +1,68 @@ +package com.example.solidconnection.util; + +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.util.Date; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; + +@Component +public class JwtUtils { + + private static final String TOKEN_HEADER = "Authorization"; + private static final String TOKEN_PREFIX = "Bearer "; + + private JwtUtils() { + } + + public static String parseTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(TOKEN_HEADER); + if (token == null || token.isBlank() || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + return token.substring(TOKEN_PREFIX.length()); + } + + public static String parseSubjectIgnoringExpiration(String token, String secretKey) { + try { + return parseClaims(token, secretKey).getSubject(); + } catch (ExpiredJwtException e) { + return e.getClaims().getSubject(); + } catch (Exception e) { + throw new CustomException(INVALID_TOKEN); + } + } + + public static String parseSubject(String token, String secretKey) { + try { + return parseClaims(token, secretKey).getSubject(); + } catch (Exception e) { + throw new CustomException(INVALID_TOKEN); + } + } + + public static boolean isExpired(String token, String secretKey) { + try { + Date expiration = Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getExpiration(); + return expiration.before(new Date()); + } catch (Exception e) { + return true; + } + } + + public static Claims parseClaims(String token, String secretKey) throws ExpiredJwtException { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java new file mode 100644 index 000000000..ed67acac0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -0,0 +1,78 @@ +package com.example.solidconnection.util; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static com.example.solidconnection.type.RedisConstants.CREATE_LOCK_PREFIX; +import static com.example.solidconnection.type.RedisConstants.REFRESH_LOCK_PREFIX; +import static com.example.solidconnection.type.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX; +import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_KEY_PREFIX; + +@Component +public class RedisUtils { + + private final RedisTemplate redisTemplate; + + @Autowired + public RedisUtils(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public List getKeysOrderByExpiration(String pattern) { + Set keys = redisTemplate.keys(pattern); + if (keys == null || keys.isEmpty()) { + return Collections.emptyList(); + } + return keys.stream() + .sorted(Comparator.comparingLong(this::getExpirationTime)) + .collect(Collectors.toList()); + } + + public Long getExpirationTime(String key) { + return redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + } + + public String getPostViewCountRedisKey(Long postId) { + return VIEW_COUNT_KEY_PREFIX.getValue() + postId; + } + + public String getValidatePostViewCountRedisKey(long siteUserId, Long postId) { + return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + siteUserId; + } + + public Long getPostIdFromPostViewCountRedisKey(String key) { + return Long.parseLong(key.substring(VIEW_COUNT_KEY_PREFIX.getValue().length())); + } + + public String generateCacheKey(String keyPattern, Object[] args) { + for (int i = 0; i < args.length; i++) { + // 키 패턴에 {i}가 포함된 경우에만 해당 인덱스의 파라미터를 삽입 + if (keyPattern.contains("{" + i + "}")) { + String replacement = (args[i] != null) ? args[i].toString() : "null"; + keyPattern = keyPattern.replace("{" + i + "}", replacement); + } + } + return keyPattern; + } + + public String getCreateLockKey(String key) { + return CREATE_LOCK_PREFIX.getValue() + key; + } + + public String getRefreshLockKey(String key) { + return REFRESH_LOCK_PREFIX.getValue() + key; + } + + public boolean isCacheExpiringSoon(String key, Long defaultTtl, Double percent) { + Long leftTtl = redisTemplate.getExpire(key); + return defaultTtl != null && ((double) leftTtl / defaultTtl) * 100 < percent; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..a644c7e9f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,25 @@ +spring: + config: + import: + - classpath:/secret/application-cloud.yml + - classpath:/secret/application-db.yml + - classpath:/secret/application-variable.yml + + tomcat: + threads: + min-spare: 20 # default 10 + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + mvc: + path match: + matching-strategy: ANT_PATH_MATCHER + +management: + endpoints: + web: + exposure: + include: prometheus diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 000000000..5ae08770d --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,268 @@ +INSERT INTO region (code, korean_name) +VALUES ('ASIA', '아시아권'), + ('AMERICAS', '미주권'), + ('CHINA', '중국권'), + ('EUROPE', '유럽권'); + +INSERT INTO country (code, korean_name, region_code) +VALUES ('BN', '브루나이', 'ASIA'), + ('SG', '싱가포르', 'ASIA'), + ('AZ', '아제르바이잔', 'ASIA'), + ('ID', '인도네시아', 'ASIA'), + ('JP', '일본', 'ASIA'), + ('TR', '튀르키예', 'ASIA'), + ('HK', '홍콩', 'ASIA'), + ('US', '미국', 'AMERICAS'), + ('CA', '캐나다', 'AMERICAS'), + ('AU', '호주', 'ASIA'), + ('BR', '브라질', 'AMERICAS'), + ('NL', '네덜란드', 'EUROPE'), + ('NO', '노르웨이', 'EUROPE'), + ('DK', '덴마크', 'EUROPE'), + ('DE', '독일', 'EUROPE'), + ('SE', '스웨덴', 'EUROPE'), + ('CH', '스위스', 'EUROPE'), + ('ES', '스페인', 'EUROPE'), + ('GB', '영국', 'EUROPE'), + ('AT', '오스트리아', 'EUROPE'), + ('IT', '이탈리아', 'EUROPE'), + ('CZ', '체코', 'EUROPE'), + ('PT', '포르투갈', 'EUROPE'), + ('FR', '프랑스', 'EUROPE'), + ('FI', '핀란드', 'EUROPE'), + ('CN', '중국', 'CHINA'), + ('TW', '대만', 'CHINA'), + ('HU', '헝가리', 'EUROPE'), + ('LT', '리투아니아', 'EUROPE'), + ('TH', '태국', 'ASIA'), + ('UZ', '우즈베키스탄', 'ASIA'), + ('KZ', '카자흐스탄', 'ASIA'), + ('IL', '이스라엘', 'ASIA'), + ('MY', '말레이시아', 'ASIA'), + ('RU', '러시아', 'EUROPE'); + +INSERT INTO site_user (birth, email, nickname, profile_image_url, gender, preparation_stage, role, password, auth_type) +VALUES ('1999-01-01', 'test@test.email', 'yonso','https://github.com/nayonsoso.png', + 'FEMALE', 'CONSIDERING', 'MENTEE', + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'); -- 12341234 + +INSERT INTO university(id, country_code, region_code, english_name, format_name, korean_name, + accommodation_url, english_course_url, homepage_url, + details_for_local, logo_image_url, background_image_url) +VALUES (1, 'US', 'AMERICAS', 'University of Guam', 'university_of_guam', '괌대학', + 'https://www.uog.edu/life-at-uog/residence-halls/', 'https://www.uog.edu/admissions/course-schedule', + 'https://www.uog.edu/admissions/international-students', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/1.png'), + (2, 'US', 'AMERICAS', 'University of Nevada, Las Vegas', 'university_of_nevada_las_vegas', '네바다주립대학 라스베이거스', + 'https://www.unlv.edu/housing', 'https://www.unlv.edu/engineering/academic-programs', + 'https://www.unlv.edu/engineering/eip', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/1.png'), + (3, 'CA', 'AMERICAS', 'Memorial University of Newfoundland St. John''s', + 'memorial_university_of_newfoundland_st_johns', '메모리얼 대학 세인트존스', 'https://www.mun.ca/residences/', + 'https://www.mun.ca/regoff/registration-and-final-exams/course-offerings/', + 'https://mun.ca/goabroad/visiting-students-inbound/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/1.png'), + (4, 'AU', 'AMERICAS', 'University of Southern Queensland', 'university_of_southern_queensland', '서던퀸스랜드대학', + 'https://www.unisq.edu.au/current-students/support/accommodation', + 'https://www.unisq.edu.au/course/specification/current/', + 'https://www.unisq.edu.au/international/partnerships/study-abroad-exchange', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_queensland/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_queensland/1.png'), + (5, 'AU', 'AMERICAS', 'University of Sydney', 'university_of_sydney', '시드니대학', + 'https://www.sydney.edu.au/study/accommodation.html', 'www.sydney.edu.au/sydney-abroad-units', + 'https://www.sydney.edu.au/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_sydney/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_sydney/1.png'), + (6, 'AU', 'AMERICAS', 'Curtin University', 'curtin_university', '커틴대학', + 'https://www.curtin.edu.au/study/campus-life/accommodation/#perth', 'https://handbook.curtin.edu.au/', + 'https://www.curtin.edu.au/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/curtin_university/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/curtin_university/1.png'), + (7, 'DK', 'EUROPE', 'University of Southern Denmark', 'university_of_southern_denmark', '서던덴마크대학교', + 'https://www.sdu.dk/en/uddannelse/information_for_international_students/studenthousing', + 'https://www.sdu.dk/en/uddannelse/exchange_programmes', 'https://www.sdu.dk/en', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/1.png'), + (8, 'DK', 'EUROPE', 'IT University of Copenhagen', 'it_university_of_copenhagen', '코펜하겐 IT대학', + 'https://en.itu.dk/Programmes/Student-Life/Practical-information-for-international-students', + 'https://en.itu.dk/Programmes/Exchange-students/Become-an-exchange-student-at-ITU', + 'https://en.itu.dk/programmes/exchange-students/become-an-exchange-student-at-itu', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/1.png'), + (9, 'DE', 'EUROPE', 'Neu-Ulm University of Applied Sciences', 'neu-ulm_university_of_applied_sciences', + '노이울름 대학', + 'https://www.hnu.de/fileadmin/user_upload/5_Internationales/International_Incomings/Bewerbung/Housing_Broschure.pdf', + 'https://www.hnu.de/en/international/international-exchange-students/courses-taught-in-english', + 'https://www.hnu.de/en/international', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/neu-ulm_university_of_applied_sciences/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/neu-ulm_university_of_applied_sciences/1.png'), + (10, 'GB', 'EUROPE', 'University of Hull', 'university_of_hull', '헐대학', + 'https://www.hull.ac.uk/Choose-Hull/Student-life/Accommodation/accommodation.aspx', + 'https://universityofhull.app.box.com/s/mpvulz3yz0uijdt68rybce19nek0d8eh', + 'https://www.hull.ac.uk/choose-hull/study-at-hull/need-to-know/key-dates', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_hull/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_hull/1.png'), + (11, 'AT', 'EUROPE', 'University of Graz', 'university_of_graz', '그라츠 대학', + 'https://orientation.uni-graz.at/de/planning-the-arrival/accommodation/', + 'https://static.uni-graz.at/fileadmin/veranstaltungen/orientation/documents/incstud_application-courses.pdf', + 'https://www.uni-graz.at/en/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/1.png'), + (12, 'AT', 'EUROPE', 'Graz University of Technology', 'graz_university_of_technology', '그라츠공과대학', + 'https://www.tugraz.at/en/studying-and-teaching/studying-internationally/incoming-students-exchange-at-tu-graz/your-stay-at-tu-graz/preparation#c75033', + 'https://tugraz.at/go/search-courses', 'https://www.tugraz.at/en/home', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/1.png'), + (13, 'AT', 'EUROPE', 'Catholic Private University Linz', 'catholic_private_university_linz', '린츠 카톨릭 대학교', NULL, + 'https://ku-linz.at/en/ku_international/incomings/kulis', 'https://ku-linz.at/en', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/1.png'), + (14, 'AT', 'EUROPE', 'University of Applied Sciences Technikum Wien', + 'university_of_applied_sciences_technikum_wien', '빈 공과대학교', NULL, + 'https://www.technikum-wien.at/en/international/student-mobility/', + 'https://www.technikum-wien.at/international/studierendenmobilitaet-2/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_applied_sciences_technikum_wien/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_applied_sciences_technikum_wien/1.png'), + (15, 'FR', 'EUROPE', 'IPSA', 'ipsa', 'IPSA', 'https://www.ipsa.fr/en/student-life/pratical-information/', NULL, + 'https://www.ipsa.fr/en/engineering-school/aeronautical-space', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/ipsa/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/ipsa/1.png'), + (16, 'JP', 'ASIA', 'Meiji University', 'meiji_university', '메이지대학', + 'https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf', NULL, + 'https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/1.png'), + (17, 'JP', 'ASIA', 'BAIKA Women''s University', 'baika_womens_university', '바이카여자대학', + 'https://dormy-ac.com/page/baika/', NULL, 'https://www.baika.ac.jp/english/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/baika_womens_university/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/baika_womens_university/1.png'), + (18, 'JP', 'ASIA', 'Bunkyo Gakuin University', 'bunkyo_gakuin_university', '분쿄가쿠인대학', NULL, NULL, + 'https://www.bgu.ac.jp/', NULL, + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/bunkyo_gakuin_university/logo.png', + 'https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/bunkyo_gakuin_university/1.png'); + +INSERT INTO university_info_for_apply(term, university_id, korean_name, semester_requirement, student_capacity, + semester_available_for_dispatch, tuition_fee_type, details_for_major, + details_for_apply, details_for_language, details_for_english_course, + details_for_accommodation, details) +VALUES ('2024-1', 1, '괌대학(A형)', 2, 1, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', '파견대학에 지원하는 전공과 본교 전공이 일치해야함', NULL, + '외국어 성적 유효기간이 파견대학의 지원시까지 유효해야함', NULL, NULL, NULL), + ('2024-1', 1, '괌대학(B형)', 2, 2, 'IRRELEVANT', 'OVERSEAS_UNIVERSITY_PAYMENT', '파견대학에 지원하는 전공과 본교 전공이 일치해야함', NULL, + '외국어 성적 유효기간이 파견대학의 지원시까지 유효해야함', NULL, NULL, '등록금 관련 정보: https://www.uog.edu/financial-aid/cost-to-attend'), + ('2024-1', 2, '네바다주립대학 라스베이거스(B형)', 2, 5, 'IRRELEVANT', 'OVERSEAS_UNIVERSITY_PAYMENT', + '- 지원가능전공: 공학계열 관련 전공자
- 파견대학에 지원하는 전공과 본교 전공이 일치해야함', + NULL, '영어 점수는 다음의 세부영역 점수를 각각 만족해야 함
- IELTS : 모든 영역에서 5.5 이상', NULL, NULL, + ' - The Engineering International Programs (EIP) Programs 안의 글로벌 하이브리드 프로그램으로 선발됨
※ 하이브리드 프로그램: 정규 과목 + 비정규 General Education Courses 과목 수강으로 구성, 정규(약 6학점) / 비정규 (약 135시간 이상) 수업 수강 (세부사항 변동 가능)
- 기숙사가 있지만 기숙사 확정이 늦게 발표되고 전원보장이 어려워, 외부숙소로 진행될 수도 있음, 한 학기 기숙사 비용: 약 $4,500~$6,000
- 한 학기 등록금: 약 $7,500
- International Program and Service Fees $2,500'), + ('2024-1', 3, '메모리얼 대학 세인트존스(A형)', 2, 4, 'ONE_SEMESTER', 'HOME_UNIVERSITY_PAYMENT', + '타전공 지원 및 수강 가능
- 지원불가능전공: Medicine, Pharmacy, Social work, Nursing
- Computer Science, Music 지원 제한적', + NULL, + '영어 점수는 다음의 세부영역 점수를 각각 만족해야함
- TOEFL iBT : 읽기/쓰기 20점, 듣기/말하기 17점 이상
- IELTS : 모든 영역에서 6.0 이상
- 외국어 성적 유효기간이 파견대학의 학기 시작하는 날까지 유효해야함 ', + NULL, NULL, NULL), + ('2024-1', 3, '메모리얼 대학 세인트존스(B형)', 2, 5, 'IRRELEVANT', 'OVERSEAS_UNIVERSITY_PAYMENT', + '타전공 지원 및 수강 가능
- 지원불가능전공: Medicine, Pharmacy, Social work, Nursing
- Computer Science, Music 지원 제한적', + NULL, + '영어 점수는 다음의 세부영역 점수를 각각 만족해야함
- TOEFL iBT : 읽기/쓰기 20점, 듣기/말하기 17점 이상
- IELTS : 모든 영역에서 6.0 이상
- 외국어 성적 유효기간이 파견대학의 학기 시작하는 날까지 유효해야함 ', + NULL, NULL, '국제학생 등록금 적용 (학점당 $2,080)'), + ('2024-1', 4, '서던퀸스랜드대학(B형)', 2, 5, 'ONE_SEMESTER', 'OVERSEAS_UNIVERSITY_PAYMENT', + '- 타전공 지원 및 수강 가능
- 미술 계열, 간호학, 약학, 교육학 등 제한 있음
- 학과별 지원 자격요건이 있는 경우 모두 충족해야 하며, 사전 승인 필요', NULL, + '영어 점수는 다음의 세부영역 점수를 각각 만족해야 함
- IELTS: 각 영역 최소 5.5 이상
- 외국어 성적 유효기간이 파견대학의 지원시까지 유효해야함 ', NULL, NULL, + '서던퀸스랜드대학은 Trimester로 운영되므로 학사일정을 반드시 참고하길 바람
- In-state 등록금 납부
(등록금 관련 정보 : https://www.unisq.edu.au/international/partnerships/study-abroad-exchange/fees-scholarships)'), + ('2024-1', 5, '시드니대학', 2, 5, 'IRRELEVANT', 'OVERSEAS_UNIVERSITY_PAYMENT', + '타전공 지원 및 수강 가능
- MECO, CAEL, LAWS unit 수강 여석 제한 있음', + NULL, + '영어 점수는 다음의 세부영역 점수를 각각 만족해야함
- IELTS: 모든 영역에서 6.0 이상
- TOEFL IBT: 읽기/듣기/말하기 17점, 쓰기 19점 이상
- 어학성적은 파견학기 시작시까지 유효하여야함', + NULL, NULL, 'OSHC(Overseas Student Health Cover) 국제학생 보험가입 의무 (2023년 기준 AUD 348/학기, 학기마다 비용 상이)'), + ('2024-1', 6, '커틴대학(A형)', 2, 3, 'ONE_SEMESTER', 'HOME_UNIVERSITY_PAYMENT', + '타전공 지원 및 수강 가능
지원 불가능 전공: Physiotherapy, Medicine, Nursing, Occupational Therapy ', NULL, + '영어 점수는 다음의 세부영역 점수를 각각 만족해야함
- IELTS: 모든 영역에서 6.0 이상
- TOEFL IBT: 읽기 13점, 쓰기 21점, 듣기 13점, 말하기 18점 이상
- 어학성적은 파견학기 시작시까지 유효하여야함', + NULL, NULL, + '※ 24-1학기에 한하여 ''Destination Australia Cheung Kong Exchange Program Scholarship'' 지급 예정 (신청자 중 가장 총점이 우수한 학생 1명에게 AUD$6000 지급, 상세 내용은 국제처 홈페이지 해외대학정보 공지글 참고)'), + ('2024-1', 7, '서던덴마크대학교', 4, 2, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', + '- 주전공과 지원전공이 반드시 일치할 필요는 없으나 본교에서 기초과목을 이수하여야 함
- 교환학생에게 제공되는 수업만 수강 가능
- Faculty of Engineering 내에서 2/3이상의 수업을 수강하여야 함
- 30 ECTS 수강', + '- 어학성적표가 해당 대학 신청서 제출 시 유효하여야 함(~10월 1일)', NULL, NULL, '- 교외 숙소', NULL), + ('2024-1', 8, '코펜하겐 IT대학', 2, 2, 'ONE_SEMESTER', 'HOME_UNIVERSITY_PAYMENT', + '- 본교 기초과목 이수사항에 따라 지원이 제한될 수 있으나 소속전공과 정확하게 일치 하지 않아도 지원은 가능(연관 전공이어야 함)
- 최소 7.5 ECTS, 최대 30ECTS 수강 가능
- 교차 수강 가능(선수과목이 지정되어있는 과목은 사전에 이수하여야 수강이 가능함)', + '- 어학성적표가 해당 대학 신청서 제출 시 유효하여야 함(~11월 1일)', NULL, NULL, '- 제공(학교 운영 기숙사 아님)
- 선착순 배정', NULL), + ('2024-1', 9, '노이울름 대학', 2, 3, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', '타전공 지원 및 수강 가능', NULL, + '영어 점수는 다음의 세부영역 점수를 각각 만족해야 함
- TOEFL IBT: 읽기 18점; 듣기 17점, 말하기 20점, 쓰기 17점
- TOEIC: 읽기 385점, 듣기 400점, 말하기 160점, 쓰기 150점
외국어 성적 유효기간이 파견대학의 학기 시작하는 시점까지 유효해야 함', + NULL, NULL, NULL), + ('2024-1', 10, '헐대학', 4, 3, 'ONE_SEMESTER', 'HOME_UNIVERSITY_PAYMENT', + '제한학과 많음. (Factsheet참조및Factsheet언급된 제한학과 외에도 학기마다 제한학과 발생가능성있음). 지원 전 권역 담당자랑 사전상담 요망. 학기당 30ECTS수강해야 LA승인남. 성적처리 늦은 편이라 8차 학기 수학자는 성적처리 늦은 거 감안하고 추가 이에 따른 불편함이 있음을 인지후 지원요망. ', + '지원 전 권역 담당자와 사전상담 요망', + '- 영어 점수는 다음의 세부영역 점수를 각각 만족해야 함
- TOEFL iBT : 듣기 및 쓰기 18점, 읽기 18점, 말하기 20점, 쓰기 18점 이상
- IELTS : 모든 영역에서 6.0이상', + NULL, NULL, '영국 생활비 및 숙소비용 유럽권 지역 중 상대적으로 매우 높은편. 지원전 반드시 사전고려 요망'), + ('2024-1', 11, '그라츠 대학', 3, 2, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', '-주전공 혹은 제2전공(혹은 연계전공과) 유관학과여아 함', + '선발인원 중 차순위 합격자는 학기제한(1개 학기)이 있을 수 있음', NULL, NULL, '학교인근 외부 숙소는 있지만, 외부업체운영숙소라 대학관할아님', NULL), + ('2024-1', 12, '그라츠공과대학', 2, 2, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', '-주전공 혹은 제2전공(혹은 연계전공과) 유관학과여아 함', + '선발인원 중 차순위 합격자는 학기제한(1개 학기)이 있을 수 있음', + '- 영어 점수는 다음의 세부영역 점수를 각각 만족해야 함
- TOEFL IBT: 읽기 18점 이상, 쓰기 17점 이상, 말하기 20점 이상, 듣기 17점 이상
- IELTS: 쓰기 5.5점 이상, 말하기 6점 이상
''- TOEIC의 경우 S/W 점수 합산 310점 이상 ', + NULL, + '자체기숙사는 없음. 교환학생이 많이 지원한 학기에는 예약이 어려울 수도 있음(선착순 경우많음). 더블룸 기준약 한달에 € 340 per month (기숙사 종류게 따라 가격 차이 유) 예산잡으면됨.', + NULL), + ('2024-1', 13, '린츠 카톨릭 대학교', 3, 2, 'ONE_SEMESTER', 'HOME_UNIVERSITY_PAYMENT', + '- 지원가능전공: History, Philosophy, Art History, theology
(영어과목 수가 그리 많지는 않으므로, 사전 확인필요)
''- 학기당 최소 15ECTS 수강신청해야 함', + '봄학기에는 영어과목이 극히 제한적으로 열린다고 함. 지원 전 권역 담당자와 사전상담 요망', NULL, NULL, '학교에서 몇가지 기숙사 옵션 합격시 연결예정.', NULL), + ('2024-1', 14, '빈 공과대학교', 3, 2, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', + '지원전공과 일치하지 않아도 지원가능하나 유사전공자만 지원가능하며, 본전공과 일치하지않으면 입학 및 수강에 불리할 수 있음
''-학기당 최소 15.ECTS 수강신청해야함', + '선발인원 중 차순위 합격자는 학기제한(1개 학기)이 있을 수 있음', NULL, NULL, '기숙사없음', NULL), + ('2024-1', 15, 'IPSA', 4, 3, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', + '- 소속전공과 지원전공이 일치 또는 유사하여야 함 : 전공이 제한적이므로 반드시 홈페이지에서 지원 가능 전공을 확인할 것
- 최대 30ECTS 수강', + '- 어학성적표가 해당 대학 신청서 제출 시 유효하여야 함(~11월 15일)', NULL, NULL, '- 미제공', NULL), + ('2024-1', 16, '메이지대학', 2, 3, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', + 'https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004d1.pdf', + '*해당 학교 일정 상 10월초까지 서류제출 필요', '학부별로 기준 상이, 관련페이지 참조', NULL, NULL, NULL), + ('2024-1', 17, '바이카여자대학', 2, 1, 'IRRELEVANT', NULL, + '교환학생 지원가능 : Department of Global English, Department of Japanese culture, Department of Media and Information, Department of Psychology.', + '여학생만 신청가능', NULL, NULL, + '기숙사 없음, 계약된 외부 기숙사 사용-“Maison de Claire Ibaraki” 62,300엔/월, 2식 포함, 계약시 66,000엔 청구 (2023년 6월기준)', NULL), + ('2024-1', 18, '분쿄가쿠인대학', 2, 3, 'ONE_YEAR', 'HOME_UNIVERSITY_PAYMENT', NULL, NULL, NULL, NULL, + '기숙사 보유, off campus, 식사 미제공, 45,000~50,000엔/월', NULL); + +INSERT INTO language_requirement(language_test_type, min_score, university_info_for_apply_id) +VALUES ('TOEFL_IBT', '80', 1), + ('IELTS', '6.5', 1), + ('TOEFL_IBT', '80', 2), + ('IELTS', '6.5', 2), + ('TOEFL_IBT', '79', 3), + ('IELTS', '6.0', 3), + ('TOEFL_IBT', '88', 4), + ('IELTS', '6.5', 4), + ('TOEFL_IBT', '88', 5), + ('IELTS', '6.5', 5), + ('TOEFL_IBT', '85', 6), + ('IELTS', '6.5', 6), + ('TOEFL_IBT', '85', 7), + ('IELTS', '6.5', 7), + ('TOEFL_IBT', '80', 8), + ('IELTS', '6.0', 8), + ('TOEFL_IBT', '83', 9), + ('IELTS', '6.5', 9), + ('TOEFL_IBT', '87', 10), + ('IELTS', '6.5', 10), + ('TOEFL_IBT', '90', 11), + ('IELTS', '6.5', 11), + ('TOEFL_IBT', '85', 12), + ('IELTS', '6.5', 12), + ('TOEFL_IBT', '82', 13), + ('IELTS', '6.0', 13), + ('TOEFL_IBT', '85', 14), + ('IELTS', '6.5', 14), + ('TOEFL_IBT', '90', 15), + ('IELTS', '7.0', 15), + ('TOEFL_IBT', '85', 16), + ('IELTS', '6.5', 16), + ('DELF', 'B2', 17), + ('DALF', 'C1', 17), + ('JLPT', 'N2', 18), + ('JLPT', 'N1', 19), + ('TOEFL_IBT', '85', 20), + ('IELTS', '6.5', 20); + +INSERT INTO board (code, korean_name) +VALUES ('EUROPE', '유럽권'), + ('AMERICAS', '미주권'), + ('ASIA', '아시아권'), + ('FREE', '자유게시판'); diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 000000000..8910ad629 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,259 @@ +CREATE TABLE IF NOT EXISTS application +( + id BIGINT AUTO_INCREMENT NOT NULL, + term VARCHAR(50) NOT NULL, + site_user_id BIGINT NULL, + nickname_for_apply VARCHAR(100) NULL, + update_count INT DEFAULT 0 NOT NULL, + verify_status VARCHAR(50) DEFAULT 'PENDING' NOT NULL, + gpa DOUBLE NOT NULL, + gpa_criteria DOUBLE NOT NULL, + language_test_type ENUM ('CEFR','DALF','DELF','DUOLINGO','IELTS','JLPT','NEW_HSK','TCF','TEF','TOEFL_IBT','TOEFL_ITP','TOEIC') NOT NULL, + language_test_score VARCHAR(255) NOT NULL, + gpa_report_url VARCHAR(500) NOT NULL, + language_test_report_url VARCHAR(500) NOT NULL, + first_choice_university_id BIGINT NULL, + second_choice_university_id BIGINT NULL, + third_choice_university_id BIGINT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS board +( + code VARCHAR(20) NOT NULL, + korean_name VARCHAR(20) NOT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (code) +); + +CREATE TABLE IF NOT EXISTS comment +( + created_at datetime NULL, + id BIGINT AUTO_INCREMENT NOT NULL, + parent_id BIGINT NULL, + post_id BIGINT NULL, + site_user_id BIGINT NULL, + updated_at datetime NULL, + content VARCHAR(255) NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS country +( + code VARCHAR(2) NOT NULL, + region_code VARCHAR(10) NULL, + korean_name VARCHAR(100) NOT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (code) +); + +CREATE TABLE IF NOT EXISTS interested_country +( + country_code VARCHAR(2) NULL, + id BIGINT AUTO_INCREMENT NOT NULL, + site_user_id BIGINT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS interested_region +( + id BIGINT AUTO_INCREMENT NOT NULL, + site_user_id BIGINT NULL, + region_code VARCHAR(10) NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS language_requirement +( + id BIGINT AUTO_INCREMENT NOT NULL, + university_info_for_apply_id BIGINT NULL, + language_test_type ENUM ('CEFR','DALF','DELF','DUOLINGO','IELTS','JLPT','NEW_HSK','TCF','TEF','TOEFL_IBT','TOEFL_ITP','TOEIC') NOT NULL, + min_score VARCHAR(255) NOT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS liked_university +( + id BIGINT AUTO_INCREMENT NOT NULL, + site_user_id BIGINT NULL, + university_info_for_apply_id BIGINT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS post +( + is_question BIT(1) NULL, + created_at datetime NULL, + id BIGINT AUTO_INCREMENT NOT NULL, + like_count BIGINT NULL, + site_user_id BIGINT NULL, + updated_at datetime NULL, + view_count BIGINT NULL, + board_code VARCHAR(20) NULL, + content VARCHAR(1000) NULL, + category ENUM ('자유','전체','질문') NULL, + title VARCHAR(255) NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS post_image +( + id BIGINT AUTO_INCREMENT NOT NULL, + post_id BIGINT NULL, + url VARCHAR(500) NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS post_like +( + id BIGINT AUTO_INCREMENT NOT NULL, + post_id BIGINT NULL, + site_user_id BIGINT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS region +( + code VARCHAR(10) NOT NULL, + korean_name VARCHAR(100) NOT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (code) +); + +CREATE TABLE IF NOT EXISTS site_user +( + quited_at date NULL, + id BIGINT AUTO_INCREMENT NOT NULL, + nickname_modified_at datetime NULL, + birth VARCHAR(20) NOT NULL, + email VARCHAR(100) NOT NULL, + nickname VARCHAR(100) NOT NULL, + profile_image_url VARCHAR(500) NULL, + gender ENUM ('FEMALE','MALE','PREFER_NOT_TO_SAY') NOT NULL, + preparation_stage ENUM ('AFTER_EXCHANGE','CONSIDERING','PREPARING_FOR_DEPARTURE','STUDYING_ABROAD') NOT NULL, + `role` ENUM ('MENTEE','MENTOR') NOT NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS university +( + id BIGINT AUTO_INCREMENT NOT NULL, + region_code VARCHAR(10) NULL, + country_code VARCHAR(2) NULL, + format_name VARCHAR(100) NOT NULL, + english_name VARCHAR(100) NOT NULL, + korean_name VARCHAR(100) NOT NULL, + background_image_url VARCHAR(500) NOT NULL, + logo_image_url VARCHAR(500) NOT NULL, + details_for_local VARCHAR(1000) NULL, + homepage_url VARCHAR(500) NULL, + english_course_url VARCHAR(500) NULL, + accommodation_url VARCHAR(500) NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS university_info_for_apply +( + id BIGINT AUTO_INCREMENT NOT NULL, + term VARCHAR(50) NOT NULL, + university_id BIGINT NULL, + korean_name VARCHAR(100) NOT NULL, + student_capacity INT NULL, + tuition_fee_type ENUM ('HOME_UNIVERSITY_PAYMENT','MIXED_PAYMENT','OVERSEAS_UNIVERSITY_PAYMENT') NULL, + semester_available_for_dispatch ENUM ('FOUR_SEMESTER','IRRELEVANT','NO_DATA','ONE_OR_TWO_SEMESTER','ONE_SEMESTER','ONE_YEAR') NULL, + details_for_language VARCHAR(1000) NULL, + gpa_requirement VARCHAR(100) NULL, + gpa_requirement_criteria VARCHAR(100) NULL, + semester_requirement VARCHAR(100) NULL, + details_for_apply VARCHAR(1000) NULL, + details_for_major VARCHAR(1000) NULL, + details_for_english_course VARCHAR(1000) NULL, + details_for_accommodation VARCHAR(1000) NULL, + details VARCHAR(500) NULL, + CONSTRAINT `PRIMARY` PRIMARY KEY (id) +); + +ALTER TABLE site_user + ADD CONSTRAINT site_user_email_unique UNIQUE (email); + +ALTER TABLE comment + ADD CONSTRAINT FK11tfff2an5hdv747cktxbdi6t FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FK11tfff2an5hdv747cktxbdi6t ON comment (site_user_id); + +ALTER TABLE interested_country + ADD CONSTRAINT FK26u5am55jefclcd7r5smk8ai7 FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FK26u5am55jefclcd7r5smk8ai7 ON interested_country (site_user_id); + +ALTER TABLE interested_region + ADD CONSTRAINT FK7h2182pqkavi9d8o2pku6gidi FOREIGN KEY (region_code) REFERENCES region (code) ON DELETE NO ACTION; + +CREATE INDEX FK7h2182pqkavi9d8o2pku6gidi ON interested_region (region_code); + +ALTER TABLE interested_country + ADD CONSTRAINT FK7x4ad24lblkq2ss0920uqfd6s FOREIGN KEY (country_code) REFERENCES country (code) ON DELETE NO ACTION; + +CREATE INDEX FK7x4ad24lblkq2ss0920uqfd6s ON interested_country (country_code); + +ALTER TABLE university_info_for_apply + ADD CONSTRAINT FKd0257hco6uy2utd1xccjh3fal FOREIGN KEY (university_id) REFERENCES university (id) ON DELETE NO ACTION; + +CREATE INDEX FKd0257hco6uy2utd1xccjh3fal ON university_info_for_apply (university_id); + +ALTER TABLE post + ADD CONSTRAINT FKfu9q9o3mlqkd58wg45ykgu8ni FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FKfu9q9o3mlqkd58wg45ykgu8ni ON post (site_user_id); + +ALTER TABLE post_like + ADD CONSTRAINT FKgx1v0whinnoqveopoh6tb4ykb FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FKgx1v0whinnoqveopoh6tb4ykb ON post_like (site_user_id); + +ALTER TABLE liked_university + ADD CONSTRAINT FKhj3gn3mqmfeiiw9jt83g7t3rk FOREIGN KEY (university_info_for_apply_id) REFERENCES university_info_for_apply (id) ON DELETE NO ACTION; + +CREATE INDEX FKhj3gn3mqmfeiiw9jt83g7t3rk_idx ON liked_university (university_info_for_apply_id); + +ALTER TABLE interested_region + ADD CONSTRAINT FKia6h0pbisqhgm3lkeya6vqo4w FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FKia6h0pbisqhgm3lkeya6vqo4w ON interested_region (site_user_id); + +ALTER TABLE country + ADD CONSTRAINT FKife035f2scmgcutdtv6bfd6g8 FOREIGN KEY (region_code) REFERENCES region (code) ON DELETE NO ACTION; + +CREATE INDEX FKife035f2scmgcutdtv6bfd6g8 ON country (region_code); + +ALTER TABLE university + ADD CONSTRAINT FKksoyt17h0te1ra588y4a3208r FOREIGN KEY (country_code) REFERENCES country (code) ON DELETE NO ACTION; + +CREATE INDEX FKksoyt17h0te1ra588y4a3208r ON university (country_code); + +ALTER TABLE university + ADD CONSTRAINT FKpwr8ocev54r8d22wdyj4a37bc FOREIGN KEY (region_code) REFERENCES region (code) ON DELETE NO ACTION; + +CREATE INDEX FKpwr8ocev54r8d22wdyj4a37bc ON university (region_code); + +ALTER TABLE language_requirement + ADD CONSTRAINT FKr75pgslwfbrvjkfau6dwtlg8l FOREIGN KEY (university_info_for_apply_id) REFERENCES university_info_for_apply (id) ON DELETE NO ACTION; + +CREATE INDEX FKr75pgslwfbrvjkfau6dwtlg8l ON language_requirement (university_info_for_apply_id); + +ALTER TABLE liked_university + ADD CONSTRAINT FKrrhud921brslcukx6fyuh0th3 FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FKrrhud921brslcukx6fyuh0th3 ON liked_university (site_user_id); + +ALTER TABLE application + ADD CONSTRAINT FKs4s3hebtn7vwd0b4xt8msxsis FOREIGN KEY (site_user_id) REFERENCES site_user (id) ON DELETE NO ACTION; + +CREATE INDEX FKs4s3hebtn7vwd0b4xt8msxsis ON application (site_user_id); + +ALTER TABLE application + ADD CONSTRAINT fk_university_info_for_apply_id_1 FOREIGN KEY (first_choice_university_id) REFERENCES university_info_for_apply (id) ON DELETE NO ACTION; + +CREATE INDEX fk_university_info_for_apply_id_1 ON application (first_choice_university_id); + +ALTER TABLE application + ADD CONSTRAINT fk_university_info_for_apply_id_2 FOREIGN KEY (second_choice_university_id) REFERENCES university_info_for_apply (id) ON DELETE NO ACTION; + +CREATE INDEX fk_university_info_for_apply_id_2 ON application (second_choice_university_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__add_gpa_score_and_language_test_score.sql b/src/main/resources/db/migration/V2__add_gpa_score_and_language_test_score.sql new file mode 100644 index 000000000..ad3ab10b0 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_gpa_score_and_language_test_score.sql @@ -0,0 +1,95 @@ +create table gpa_score ( + gpa float(53) not null, + gpa_criteria float(53) not null, + issue_date date, + created_at datetime(6), + id bigint not null auto_increment, + site_user_id bigint, + updated_at datetime(6), + gpa_report_url varchar(500) not null, + rejected_reason varchar(255), + verify_status varchar(50) not null default 'PENDING', + primary key (id) +) engine=InnoDB; + +alter table gpa_score + add constraint FK2k65qncfxvol5j4l4hb7d6iv1 + foreign key (site_user_id) + references site_user (id); + +create table language_test_score ( + issue_date date, + created_at datetime(6), + id bigint not null auto_increment, + site_user_id bigint, + updated_at datetime(6), + language_test_type enum ('CEFR', 'DALF', 'DELF', 'DUOLINGO', 'IELTS', 'JLPT', 'NEW_HSK', 'TCF', 'TEF', 'TOEFL_IBT', 'TOEFL_ITP', 'TOEIC') not null, + language_test_report_url varchar(500) not null, + language_test_score varchar(255) not null, + rejected_reason varchar(255), + verify_status varchar(50) not null default 'PENDING', + primary key (id) +) engine=InnoDB; + +alter table language_test_score + add constraint FKt2uevj2r4iuxumblj5ofbgmqn + foreign key (site_user_id) + references site_user (id); + +alter table application add column is_delete bit; + +alter table application drop foreign key fk_university_info_for_apply_id_1; +alter table application drop foreign key fk_university_info_for_apply_id_2; + +alter table application + add constraint FKi822ljuirbu9o0lnd9jt7l7qg + foreign key (first_choice_university_id) + references university_info_for_apply (id); + +alter table application + add constraint FKepp2by7frnkt1o1w3v4t4lgtu + foreign key (second_choice_university_id) + references university_info_for_apply (id); + +alter table application + add constraint FKeajojvwgn069mfxhbq5ja1sws + foreign key (third_choice_university_id) + references university_info_for_apply (id); + +alter table comment + add constraint FKde3rfu96lep00br5ov0mdieyt + foreign key (parent_id) + references comment (id); + +alter table comment + add constraint FKs1slvnkuemjsq2kj4h3vhx7i1 + foreign key (post_id) + references post (id); + +alter table liked_university drop foreign key FKhj3gn3mqmfeiiw9jt83g7t3rk; +alter table liked_university drop foreign key FKrrhud921brslcukx6fyuh0th3; + +alter table liked_university + add constraint FKkuqxb64dnfrl7har8t5ionw83 + foreign key (site_user_id) + references site_user (id); + +alter table liked_university + add constraint FKo317gq6apc3a091w32qhidtjt + foreign key (university_info_for_apply_id) + references university_info_for_apply (id); + +alter table post + add constraint FKlpnkhhbfb3gg3tfreh2a7qh8b + foreign key (board_code) + references board (code); + +-- alter table post_image +-- add constraint FKsip7qv57jw2fw50g97t16nrjr +-- foreign key (post_id) +-- references post (id); + +alter table post_like + add constraint FKj7iy0k7n3d0vkh8o7ibjna884 + foreign key (post_id) + references post (id); diff --git a/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql b/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql new file mode 100644 index 000000000..e89c4aa1b --- /dev/null +++ b/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql @@ -0,0 +1,13 @@ +ALTER TABLE site_user +ADD COLUMN auth_type ENUM('KAKAO', 'APPLE', 'EMAIL'); + +UPDATE site_user +SET auth_type = 'KAKAO' +WHERE auth_type IS NULL; + +ALTER TABLE site_user +MODIFY COLUMN auth_type ENUM('KAKAO', 'APPLE', 'EMAIL') NOT NULL; + +ALTER TABLE site_user +ADD CONSTRAINT uk_site_user_email_auth_type +UNIQUE (email, auth_type); diff --git a/src/main/resources/db/migration/V4__remove_issue_date_columns.sql b/src/main/resources/db/migration/V4__remove_issue_date_columns.sql new file mode 100644 index 000000000..9a8e0700b --- /dev/null +++ b/src/main/resources/db/migration/V4__remove_issue_date_columns.sql @@ -0,0 +1,5 @@ +ALTER TABLE gpa_score + DROP COLUMN issue_date; + +ALTER TABLE language_test_score + DROP COLUMN issue_date; \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__add_password_column.sql b/src/main/resources/db/migration/V5__add_password_column.sql new file mode 100644 index 000000000..948e2a97d --- /dev/null +++ b/src/main/resources/db/migration/V5__add_password_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_user +ADD COLUMN password VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql b/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql new file mode 100644 index 000000000..a661a2a61 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_user + modify ROLE enum ('MENTEE', 'MENTOR', 'ADMIN') NOT NULL; diff --git a/src/main/resources/db/migration/V7__expand_details_column_length.sql b/src/main/resources/db/migration/V7__expand_details_column_length.sql new file mode 100644 index 000000000..452cc12a7 --- /dev/null +++ b/src/main/resources/db/migration/V7__expand_details_column_length.sql @@ -0,0 +1,2 @@ +ALTER TABLE university_info_for_apply + modify details VARCHAR(1000) NULL; \ No newline at end of file diff --git a/src/main/resources/scripts/incrViewCount.lua b/src/main/resources/scripts/incrViewCount.lua new file mode 100644 index 000000000..9421c0739 --- /dev/null +++ b/src/main/resources/scripts/incrViewCount.lua @@ -0,0 +1,14 @@ +-- Define the key and TTL (Time To Live) from KEYS and ARGV +local key = KEYS[1] +local ttl = tonumber(ARGV[1]) + +-- Attempt to set the key with value 1 if it does not already exist, and set TTL +local result = redis.call('SET', key, 1, 'NX', 'EX', ttl) + +-- If the key was set, it means it did not exist and we set it +if result then + return 1 +else + -- If the key was not set, it means it already existed, so increment the value + return redis.call('INCR', key) +end diff --git a/src/main/resources/secret b/src/main/resources/secret new file mode 160000 index 000000000..95ffe4882 --- /dev/null +++ b/src/main/resources/secret @@ -0,0 +1 @@ +Subproject commit 95ffe48824a26d4b0c52c5c03a8cf40fc2cda098 diff --git a/src/test/java/com/example/solidconnection/SolidConnectionApplicationTests.java b/src/test/java/com/example/solidconnection/SolidConnectionApplicationTests.java new file mode 100644 index 000000000..3a68f4d45 --- /dev/null +++ b/src/test/java/com/example/solidconnection/SolidConnectionApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.solidconnection; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = SolidConnectionApplicationTests.class) +class SolidConnectionApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java new file mode 100644 index 000000000..f06116ebb --- /dev/null +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java @@ -0,0 +1,214 @@ +package com.example.solidconnection.application.service; + +import com.example.solidconnection.application.dto.ApplicantResponse; +import com.example.solidconnection.application.dto.ApplicationsResponse; +import com.example.solidconnection.application.dto.UniversityApplicantsResponse; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원서 조회 서비스 테스트") +class ApplicationQueryServiceTest extends BaseIntegrationTest { + + @Autowired + private ApplicationQueryService applicationQueryService; + + @Nested + class 지원자_목록_조회_테스트 { + + @Test + void 이번_학기_전체_지원자를_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_2, + "", + "" + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))), + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_7_코펜하겐IT대학_X_X_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(그라츠대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + )); + + assertThat(response.thirdChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(서던덴마크대학교_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + )); + } + + @Test + void 이번_학기_특정_지역_지원자를_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_2, + 영미권.getCode(), + "" + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) + )); + } + + @Test + void 이번_학기_지원자를_대학_국문_이름으로_필터링해서_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_2, + null, + "일본" + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(메이지대학_지원_정보, List.of()) + )); + + assertThat(response.thirdChoice()).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + ); + } + + @Test + void 이전_학기_지원자는_조회되지_않는다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_1, + "", + "" + ); + + // then + assertThat(response.firstChoice()).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(이전학기_지원서, false))) + )); + assertThat(response.secondChoice()).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(이전학기_지원서, false))) + )); + assertThat(response.thirdChoice()).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(이전학기_지원서, false))) + )); + } + } + + @Nested + class 경쟁자_목록_조회_테스트 { + + @Test + void 이번_학기_지원한_대학의_경쟁자_목록을_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( + 테스트유저_2 + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) + )); + + assertThat(response.thirdChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))) + )); + } + + @Test + void 이번_학기_지원한_대학_중_미선택이_있을_때_경쟁자_목록을_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( + 테스트유저_7 + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_7_코펜하겐IT대학_X_X_지원서, true))) + )); + + assertThat(response.secondChoice()).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, List.of()) + ); + + assertThat(response.thirdChoice()).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, List.of()) + ); + } + + @Test + void 이번_학기_지원한_대학이_모두_미선택일_때_경쟁자_목록을_조회한다() { + //when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( + 테스트유저_6 + ); + + // then + assertThat(response.firstChoice()).isEmpty(); + assertThat(response.secondChoice()).isEmpty(); + assertThat(response.thirdChoice()).isEmpty(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java new file mode 100644 index 000000000..ffd3818ce --- /dev/null +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java @@ -0,0 +1,176 @@ +package com.example.solidconnection.application.service; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.dto.ApplyRequest; +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.VerifyStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.custom.exception.ErrorCode.CANT_APPLY_FOR_SAME_UNIVERSITY; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("지원서 제출 서비스 테스트") +class ApplicationSubmissionServiceTest extends BaseIntegrationTest { + + @Autowired + private ApplicationSubmissionService applicationSubmissionService; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private GpaScoreRepository gpaScoreRepository; + + @Autowired + private LanguageTestScoreRepository languageTestScoreRepository; + + @Test + void 정상적으로_지원서를_제출한다() { + // given + GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + 네바다주립대학_라스베이거스_지원_정보.getId(), + 메모리얼대학_세인트존스_A_지원_정보.getId() + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + // when + boolean result = applicationSubmissionService.apply(테스트유저_1, request); + + // then + Application savedApplication = applicationRepository.findBySiteUserAndTerm(테스트유저_1, term).orElseThrow(); + assertAll( + () -> assertThat(result).isTrue(), + () -> assertThat(savedApplication.getGpa()).isEqualTo(gpaScore.getGpa()), + () -> assertThat(savedApplication.getLanguageTest()).isEqualTo(languageTestScore.getLanguageTest()), + () -> assertThat(savedApplication.getVerifyStatus()).isEqualTo(VerifyStatus.APPROVED), + () -> assertThat(savedApplication.getNicknameForApply()).isNotNull(), + () -> assertThat(savedApplication.getUpdateCount()).isZero(), + () -> assertThat(savedApplication.getTerm()).isEqualTo(term), + () -> assertThat(savedApplication.isDelete()).isFalse(), + () -> assertThat(savedApplication.getFirstChoiceUniversity().getId()).isEqualTo(괌대학_A_지원_정보.getId()), + () -> assertThat(savedApplication.getSecondChoiceUniversity().getId()).isEqualTo(네바다주립대학_라스베이거스_지원_정보.getId()), + () -> assertThat(savedApplication.getThirdChoiceUniversity().getId()).isEqualTo(메모리얼대학_세인트존스_A_지원_정보.getId()), + () -> assertThat(savedApplication.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + ); + } + + @Test + void 미승인된_GPA_성적으로_지원하면_예외_응답을_반환한다() { + // given + GpaScore gpaScore = createUnapprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + null, + null + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply(테스트유저_1, request) + ) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_GPA_SCORE_STATUS.getMessage()); + } + + @Test + void 미승인된_어학성적으로_지원하면_예외_응답을_반환한다() { + // given + GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createUnapprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + null, + null + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply(테스트유저_1, request) + ) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_LANGUAGE_TEST_SCORE_STATUS.getMessage()); + } + + @Test + void 지원서_수정_횟수를_초과하면_예외_응답을_반환한다() { + // given + GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + null, + null + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + for (int i = 0; i < APPLICATION_UPDATE_COUNT_LIMIT + 1; i++) { + applicationSubmissionService.apply(테스트유저_1, request); + } + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply(테스트유저_1, request) + ) + .isInstanceOf(CustomException.class) + .hasMessage(APPLY_UPDATE_LIMIT_EXCEED.getMessage()); + } + + private GpaScore createUnapprovedGpaScore(SiteUser siteUser) { + GpaScore gpaScore = new GpaScore( + new Gpa(4.0, 4.5, "/gpa-report.pdf"), + siteUser + ); + return gpaScoreRepository.save(gpaScore); + } + + private GpaScore createApprovedGpaScore(SiteUser siteUser) { + GpaScore gpaScore = new GpaScore( + new Gpa(4.0, 4.5, "/gpa-report.pdf"), + siteUser + ); + gpaScore.setVerifyStatus(VerifyStatus.APPROVED); + return gpaScoreRepository.save(gpaScore); + } + + private LanguageTestScore createUnapprovedLanguageTestScore(SiteUser siteUser) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), + siteUser + ); + return languageTestScoreRepository.save(languageTestScore); + } + + private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), + siteUser + ); + languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); + return languageTestScoreRepository.save(languageTestScore); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java new file mode 100644 index 000000000..f5616973f --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java @@ -0,0 +1,187 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.JwtUtils; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("인증 토큰 제공자 테스트") +class AuthTokenProviderTest { + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + private SiteUser siteUser; + private String subject; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + subject = siteUser.getId().toString(); + } + + @Nested + class 액세스_토큰을_제공한다 { + + @Test + void SiteUser_로_액세스_토큰을_생성한다() { + // when + String token = authTokenProvider.generateAccessToken(siteUser); + + // then + String actualSubject = JwtUtils.parseSubject(token, jwtProperties.secret()); + assertThat(actualSubject).isEqualTo(subject); + } + + @Test + void subject_로_액세스_토큰을_생성한다() { + // given + String subject = "subject123"; + + // when + String token = authTokenProvider.generateAccessToken(subject); + + // then + String actualSubject = JwtUtils.parseSubject(token, jwtProperties.secret()); + assertThat(actualSubject).isEqualTo(subject); + } + } + + @Nested + class 리프레시_토큰을_제공한다 { + + @Test + void SiteUser_로_리프레시_토큰을_생성하고_저장한다() { + // when + String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + + // then + String actualSubject = JwtUtils.parseSubject(refreshToken, jwtProperties.secret()); + String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); + assertAll( + () -> assertThat(actualSubject).isEqualTo(subject), + () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isEqualTo(refreshToken) + ); + } + + @Test + void 저장된_리프레시_토큰을_조회한다() { + // given + String refreshToken = "refreshToken"; + redisTemplate.opsForValue().set(TokenType.REFRESH.addPrefix(subject), refreshToken); + + // when + Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); + + // then + assertThat(optionalRefreshToken.get()).isEqualTo(refreshToken); + } + + @Test + void 저장되지_않은_리프레시_토큰을_조회한다() { + // when + Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); + + // then + assertThat(optionalRefreshToken).isEmpty(); + } + } + + @Nested + class 블랙리스트_토큰을_제공한다 { + + @Test + void 엑세스_토큰으로_블랙리스트_토큰을_생성하고_저장한다() { + // when + String accessToken = "accessToken"; + String blackListToken = authTokenProvider.generateAndSaveBlackListToken(accessToken); + + // then + String actualSubject = JwtUtils.parseSubject(blackListToken, jwtProperties.secret()); + String blackListTokenKey = TokenType.BLACKLIST.addPrefix(accessToken); + assertAll( + () -> assertThat(actualSubject).isEqualTo(accessToken), + () -> assertThat(redisTemplate.opsForValue().get(blackListTokenKey)).isEqualTo(blackListToken) + ); + } + + @Test + void 저장된_블랙리스트_토큰을_조회한다() { + // given + String accessToken = "accessToken"; + String blackListToken = "token"; + redisTemplate.opsForValue().set(TokenType.BLACKLIST.addPrefix(accessToken), blackListToken); + + // when + Optional optionalBlackListToken = authTokenProvider.findBlackListToken(accessToken); + + // then + assertThat(optionalBlackListToken).hasValue(blackListToken); + } + + @Test + void 저장되지_않은_블랙리스트_토큰을_조회한다() { + // when + Optional optionalBlackListToken = authTokenProvider.findBlackListToken("accessToken"); + + // then + assertThat(optionalBlackListToken).isEmpty(); + } + } + + @Test + void 토큰을_생성한다() { + // when + String subject = "subject123"; + String token = authTokenProvider.generateToken(subject, TokenType.ACCESS); + + // then + String extractedSubject = Jwts.parser() + .setSigningKey(jwtProperties.secret()) + .parseClaimsJws(token) + .getBody() + .getSubject(); + assertThat(subject).isEqualTo(extractedSubject); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java new file mode 100644 index 000000000..e9663f5df --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.EmailSignInRequest; +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("이메일 로그인 서비스 테스트") +@TestContainerSpringBootTest +class EmailSignInServiceTest { + + @Autowired + private EmailSignInService emailSignInService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + void 로그인에_성공한다() { + // given + String email = "testEmail"; + String rawPassword = "testPassword"; + SiteUser siteUser = createSiteUser(email, rawPassword); + siteUserRepository.save(siteUser); + EmailSignInRequest signInRequest = new EmailSignInRequest(siteUser.getEmail(), rawPassword); + + // when + SignInResponse signInResponse = emailSignInService.signIn(signInRequest); + + // then + assertAll( + () -> Assertions.assertThat(signInResponse.accessToken()).isNotNull(), + () -> Assertions.assertThat(signInResponse.refreshToken()).isNotNull() + ); + } + + @Nested + class 로그인에_실패한다 { + + @Test + void 이메일과_일치하는_사용자가_없으면_예외_응답을_반환한다() { + // given + EmailSignInRequest signInRequest = new EmailSignInRequest("이메일", "비밀번호"); + + // when & then + assertThatCode(() -> emailSignInService.signIn(signInRequest)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + void 비밀번호가_일치하지_않으면_예외_응답을_반환한다() { + // given + String email = "testEmail"; + SiteUser siteUser = createSiteUser(email, "testPassword"); + siteUserRepository.save(siteUser); + EmailSignInRequest signInRequest = new EmailSignInRequest(email, "틀린비밀번호"); + + // when & then + assertThatCode(() -> emailSignInService.signIn(signInRequest)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + } + } + + private SiteUser createSiteUser(String email, String rawPassword) { + String encodedPassword = passwordEncoder.encode(rawPassword); + return new SiteUser( + email, + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE, + AuthType.EMAIL, + encodedPassword + ); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java new file mode 100644 index 000000000..b80c4ca5d --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.JwtUtils; +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 java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("로그인 서비스 테스트") +@TestContainerSpringBootTest +class SignInServiceTest { + + @Autowired + private SignInService signInService; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + private String subject; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + subject = siteUser.getId().toString(); + } + + @Test + void 성공적으로_로그인한다() { + // when + SignInResponse signInResponse = signInService.signIn(siteUser); + + // then + String accessTokenSubject = JwtUtils.parseSubject(signInResponse.accessToken(), jwtProperties.secret()); + String refreshTokenSubject = JwtUtils.parseSubject(signInResponse.refreshToken(), jwtProperties.secret()); + Optional savedRefreshToken = authTokenProvider.findRefreshToken(subject); + assertAll( + () -> assertThat(accessTokenSubject).isEqualTo(subject), + () -> assertThat(refreshTokenSubject).isEqualTo(subject), + () -> assertThat(savedRefreshToken).hasValue(signInResponse.refreshToken())); + } + + @Test + void 탈퇴한_이력이_있으면_초기화한다() { + // given + siteUser.setQuitedAt(LocalDate.now().minusDays(1)); + siteUserRepository.save(siteUser); + + // when + signInService.signIn(siteUser); + + // then + assertThat(siteUser.getQuitedAt()).isNull(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java new file mode 100644 index 000000000..12ab6f666 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java @@ -0,0 +1,182 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.util.JwtUtils; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static com.example.solidconnection.auth.service.oauth.OAuthSignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("OAuth 회원가입 토큰 제공자 테스트") +class OAuthSignUpTokenProviderTest { + + @Autowired + private OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + @Test + void 회원가입_토큰을_생성하고_저장한다() { + // given + String email = "email"; + AuthType authType = AuthType.KAKAO; + + // when + String signUpToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, authType); + + // then + Claims claims = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()); + String actualSubject = claims.getSubject(); + AuthType actualAuthType = AuthType.valueOf(claims.get(AUTH_TYPE_CLAIM_KEY, String.class)); + String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); + assertAll( + () -> assertThat(actualSubject).isEqualTo(email), + () -> assertThat(actualAuthType).isEqualTo(authType), + () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) + ); + } + + @Nested + class 주어진_회원가입_토큰을_검증한다 { + + @Test + void 검증_성공한다() { + // given + String email = "email@test.com"; + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); + } + + @Test + void 만료되었으면_예외_응답을_반환한다() { + // given + String expiredToken = createExpiredToken(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(expiredToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_jwt_가_아닌_토큰() { + // given + String notJwt = "not jwt"; + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(notJwt)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_authType_클래스_불일치() { + // given + Map wrongClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, "카카오")); + String wrongAuthType = createBaseJwtBuilder().addClaims(wrongClaim).compact(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(wrongAuthType)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_subject_누락() { + // given + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String noSubject = createBaseJwtBuilder().addClaims(claim).compact(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(noSubject)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 우리_서버에_발급된_토큰이_아니면_예외_응답을_반환한다() { + // given + Map validClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject("email").compact(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(signUpToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER.getMessage()); + } + } + + @Test + void 회원가입_토큰에서_이메일을_추출한다() { + // given + String email = "email@test.com"; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when + String extractedEmail = OAuthSignUpTokenProvider.parseEmail(validToken); + + // then + assertThat(extractedEmail).isEqualTo(email); + } + + @Test + void 회원가입_토큰에서_인증_타입을_추출한다() { + // given + AuthType authType = AuthType.APPLE; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, authType); + String validToken = createBaseJwtBuilder().setSubject("email").addClaims(claim).compact(); + + // when + AuthType extractedAuthType = OAuthSignUpTokenProvider.parseAuthType(validToken); + + // then + assertThat(extractedAuthType).isEqualTo(authType); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private JwtBuilder createBaseJwtBuilder() { + return Jwts.builder() + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()); + } +} diff --git a/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java new file mode 100644 index 000000000..ee74bb90b --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java @@ -0,0 +1,408 @@ +package com.example.solidconnection.community.comment.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.dto.CommentCreateRequest; +import com.example.solidconnection.community.comment.dto.CommentCreateResponse; +import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; +import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; +import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.PostCategory; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_ID; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_LEVEL; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("댓글 서비스 테스트") +class CommentServiceTest extends BaseIntegrationTest { + + @Autowired + private CommentService commentService; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostRepository postRepository; + + @Nested + class 댓글_조회_테스트 { + + @Test + void 게시글의_모든_댓글을_조회한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + List comments = List.of(parentComment, childComment); + + // when + List responses = commentService.findCommentsByPostId( + 테스트유저_1, + testPost.getId() + ); + + // then + assertAll( + () -> assertThat(responses).hasSize(comments.size()), + () -> assertThat(responses) + .filteredOn(response -> response.id().equals(parentComment.getId())) + .singleElement() + .satisfies(response -> assertAll( + () -> assertThat(response.id()).isEqualTo(parentComment.getId()), + () -> assertThat(response.parentId()).isNull(), + () -> assertThat(response.content()).isEqualTo(parentComment.getContent()), + () -> assertThat(response.isOwner()).isTrue(), + () -> assertThat(response.createdAt()).isEqualTo(parentComment.getCreatedAt()), + () -> assertThat(response.updatedAt()).isEqualTo(parentComment.getUpdatedAt()), + + () -> assertThat(response.postFindSiteUserResponse().id()) + .isEqualTo(parentComment.getSiteUser().getId()), + () -> assertThat(response.postFindSiteUserResponse().nickname()) + .isEqualTo(parentComment.getSiteUser().getNickname()), + () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()) + .isEqualTo(parentComment.getSiteUser().getProfileImageUrl()) + )), + () -> assertThat(responses) + .filteredOn(response -> response.id().equals(childComment.getId())) + .singleElement() + .satisfies(response -> assertAll( + () -> assertThat(response.id()).isEqualTo(childComment.getId()), + () -> assertThat(response.parentId()).isEqualTo(parentComment.getId()), + () -> assertThat(response.content()).isEqualTo(childComment.getContent()), + () -> assertThat(response.isOwner()).isFalse(), + () -> assertThat(response.createdAt()).isEqualTo(childComment.getCreatedAt()), + () -> assertThat(response.updatedAt()).isEqualTo(childComment.getUpdatedAt()), + + () -> assertThat(response.postFindSiteUserResponse().id()) + .isEqualTo(childComment.getSiteUser().getId()), + () -> assertThat(response.postFindSiteUserResponse().nickname()) + .isEqualTo(childComment.getSiteUser().getNickname()), + () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()) + .isEqualTo(childComment.getSiteUser().getProfileImageUrl()) + )) + ); + } + } + + @Nested + class 댓글_생성_테스트 { + + @Test + void 댓글을_성공적으로_생성한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 댓글", null); + + // when + CommentCreateResponse response = commentService.createComment( + 테스트유저_1, + request + ); + + // then + Comment savedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedComment.getId()).isEqualTo(response.id()), + () -> assertThat(savedComment.getContent()).isEqualTo(request.content()), + () -> assertThat(savedComment.getParentComment()).isNull(), + () -> assertThat(savedComment.getPost().getId()).isEqualTo(testPost.getId()), + () -> assertThat(savedComment.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + ); + } + + @Test + void 대댓글을_성공적으로_생성한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 대댓글", parentComment.getId()); + + // when + CommentCreateResponse response = commentService.createComment( + 테스트유저_2, + request + ); + + // then + Comment savedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedComment.getId()).isEqualTo(response.id()), + () -> assertThat(savedComment.getContent()).isEqualTo(request.content()), + () -> assertThat(savedComment.getParentComment().getId()).isEqualTo(parentComment.getId()), + () -> assertThat(savedComment.getPost().getId()).isEqualTo(testPost.getId()), + () -> assertThat(savedComment.getSiteUser().getId()).isEqualTo(테스트유저_2.getId()) + ); + } + + @Test + void 대대댓글_생성_시도하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 대대댓글", childComment.getId()); + + // when & then + assertThatThrownBy(() -> + commentService.createComment( + 테스트유저_1, + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_COMMENT_LEVEL.getMessage()); + } + + @Test + void 존재하지_않는_부모댓글로_대댓글_작성시_예외를_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + long invalidCommentId = 9999L; + CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 대댓글", invalidCommentId); + + // when & then + assertThatThrownBy(() -> + commentService.createComment( + 테스트유저_1, + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_COMMENT_ID.getMessage()); + } + } + + @Nested + class 댓글_수정_테스트 { + + @Test + void 댓글을_성공적으로_수정한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "원본 댓글"); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); + + // when + CommentUpdateResponse response = commentService.updateComment( + 테스트유저_1, + comment.getId(), + request + ); + + // then + Comment updatedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(updatedComment.getId()).isEqualTo(comment.getId()), + () -> assertThat(updatedComment.getContent()).isEqualTo(request.content()), + () -> assertThat(updatedComment.getParentComment()).isNull(), + () -> assertThat(updatedComment.getPost().getId()).isEqualTo(testPost.getId()), + () -> assertThat(updatedComment.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + ); + } + + @Test + void 다른_사용자의_댓글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "원본 댓글"); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment( + 테스트유저_2, + comment.getId(), + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + + @Test + void 삭제된_댓글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, null); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment( + 테스트유저_1, + comment.getId(), + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_UPDATE_DEPRECATED_COMMENT.getMessage()); + } + } + + @Nested + class 댓글_삭제_테스트 { + + @Test + @Transactional + void 대댓글이_없는_댓글을_삭제한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "테스트 댓글"); + List comments = testPost.getCommentList(); + int expectedCommentsCount = comments.size() - 1; + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_1, + comment.getId() + ); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(comment.getId()), + () -> assertThat(commentRepository.findById(comment.getId())).isEmpty(), + () -> assertThat(testPost.getCommentList()).hasSize(expectedCommentsCount) + ); + } + + @Test + @Transactional + void 대댓글이_있는_댓글을_삭제하면_내용만_삭제된다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + List comments = testPost.getCommentList(); + List childComments = parentComment.getCommentList(); + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_1, + parentComment.getId() + ); + + // then + Comment deletedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(deletedComment.getContent()).isNull(), + () -> assertThat(deletedComment.getCommentList()) + .extracting(Comment::getId) + .containsExactlyInAnyOrder(childComment.getId()), + () -> assertThat(testPost.getCommentList()).hasSize(comments.size()), + () -> assertThat(deletedComment.getCommentList()).hasSize(childComments.size()) + ); + } + + @Test + @Transactional + void 대댓글을_삭제하면_부모댓글이_삭제되지_않는다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment1 = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글 1"); + Comment childComment2 = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글 2"); + List childComments = parentComment.getCommentList(); + int expectedChildCommentsCount = childComments.size() - 1; + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_2, + childComment1.getId() + ); + + // then + Comment remainingParentComment = commentRepository.findById(parentComment.getId()).orElseThrow(); + List remainingChildComments = remainingParentComment.getCommentList(); + assertAll( + () -> assertThat(commentRepository.findById(response.id())).isEmpty(), + () -> assertThat(remainingParentComment.getContent()).isEqualTo(parentComment.getContent()), + () -> assertThat(remainingChildComments).hasSize(expectedChildCommentsCount), + () -> assertThat(remainingChildComments) + .extracting(Comment::getId) + .containsExactly(childComment2.getId()) + ); + } + + @Test + @Transactional + void 대댓글을_삭제하고_부모댓글이_삭제된_상태면_부모댓글도_삭제된다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + List comments = testPost.getCommentList(); + int expectedCommentsCount = comments.size() - 2; + parentComment.deprecateComment(); + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_2, + childComment.getId() + ); + + // then + assertAll( + () -> assertThat(commentRepository.findById(response.id())).isEmpty(), + () -> assertThat(commentRepository.findById(parentComment.getId())).isEmpty(), + () -> assertThat(testPost.getCommentList()).hasSize(expectedCommentsCount) + ); + } + + @Test + void 다른_사용자의_댓글을_삭제하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "테스트 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.deleteCommentById( + 테스트유저_2, + comment.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "테스트 제목", + "테스트 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + return postRepository.save(post); + } + + private Comment createComment(Post post, SiteUser siteUser, String content) { + Comment comment = new Comment(content); + comment.setPostAndSiteUser(post, siteUser); + return commentRepository.save(comment); + } + + private Comment createChildComment(Post post, SiteUser siteUser, Comment parentComment, String content) { + Comment comment = new Comment(content); + comment.setPostAndSiteUser(post, siteUser); + comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); + return commentRepository.save(comment); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java new file mode 100644 index 000000000..328a1dc41 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java @@ -0,0 +1,366 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.dto.PostCreateRequest; +import com.example.solidconnection.community.post.dto.PostCreateResponse; +import com.example.solidconnection.community.post.dto.PostDeleteResponse; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateResponse; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@DisplayName("게시글 생성/수정/삭제 서비스 테스트") +class PostCommandServiceTest extends BaseIntegrationTest { + + @Autowired + private PostCommandService postCommandService; + + @MockBean + private S3Service s3Service; + + @Autowired + private RedisService redisService; + + @Autowired + private RedisUtils redisUtils; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostImageRepository postImageRepository; + + @Nested + class 게시글_생성_테스트 { + + @Test + @Transactional + void 게시글을_성공적으로_생성한다() { + // given + PostCreateRequest request = createPostCreateRequest(PostCategory.자유.name()); + List imageFiles = List.of(createImageFile()); + String expectedImageUrl = "test-image-url"; + given(s3Service.uploadFiles(any(), eq(ImgType.COMMUNITY))) + .willReturn(List.of(new UploadedFileUrlResponse(expectedImageUrl))); + + // when + PostCreateResponse response = postCommandService.createPost( + 테스트유저_1, + request, + imageFiles + ); + + // then + Post savedPost = postRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(response.id()).isEqualTo(savedPost.getId()), + () -> assertThat(savedPost.getTitle()).isEqualTo(request.title()), + () -> assertThat(savedPost.getContent()).isEqualTo(request.content()), + () -> assertThat(savedPost.getIsQuestion()).isEqualTo(request.isQuestion()), + () -> assertThat(savedPost.getCategory().name()).isEqualTo(request.postCategory()), + () -> assertThat(savedPost.getBoard().getCode()).isEqualTo(자유게시판.getCode()), + () -> assertThat(savedPost.getPostImageList()).hasSize(imageFiles.size()), + () -> assertThat(savedPost.getPostImageList()) + .extracting(PostImage::getUrl) + .containsExactly(expectedImageUrl) + ); + } + + @Test + void 전체_카테고리로_생성하면_예외_응답을_반환한다() { + // given + PostCreateRequest request = createPostCreateRequest(PostCategory.전체.name()); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.createPost(테스트유저_1, request, imageFiles)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_CATEGORY.getMessage()); + } + + @Test + void 존재하지_않는_카테고리로_생성하면_예외_응답을_반환한다() { + // given + PostCreateRequest request = createPostCreateRequest("INVALID_CATEGORY"); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.createPost(테스트유저_1, request, imageFiles)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_CATEGORY.getMessage()); + } + + @Test + void 이미지를_5개_초과하여_업로드하면_예외_응답을_반환한다() { + // given + PostCreateRequest request = createPostCreateRequest(PostCategory.자유.name()); + List imageFiles = createSixImageFiles(); + + // when & then + assertThatThrownBy(() -> + postCommandService.createPost(테스트유저_1, request, imageFiles)) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); + } + } + + @Nested + class 게시글_수정_테스트 { + + @Test + @Transactional + void 게시글을_성공적으로_수정한다() { + // given + String originImageUrl = "origin-image-url"; + String expectedImageUrl = "update-image-url"; + Post testPost = createPost(자유게시판, 테스트유저_1, originImageUrl); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = List.of(createImageFile()); + + given(s3Service.uploadFiles(any(), eq(ImgType.COMMUNITY))) + .willReturn(List.of(new UploadedFileUrlResponse(expectedImageUrl))); + + // when + PostUpdateResponse response = postCommandService.updatePost( + 테스트유저_1, + testPost.getId(), + request, + imageFiles + ); + + // then + Post updatedPost = postRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(updatedPost.getTitle()).isEqualTo(request.title()), + () -> assertThat(updatedPost.getContent()).isEqualTo(request.content()), + () -> assertThat(updatedPost.getCategory().name()).isEqualTo(request.postCategory()), + () -> assertThat(updatedPost.getPostImageList()).hasSize(imageFiles.size()), + () -> assertThat(updatedPost.getPostImageList()) + .extracting(PostImage::getUrl) + .containsExactly(expectedImageUrl) + ); + then(s3Service).should().deletePostImage(originImageUrl); + } + + @Test + void 다른_사용자의_게시글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.updatePost( + 테스트유저_2, + testPost.getId(), + request, + imageFiles + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + + @Test + void 질문_게시글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createQuestionPost(자유게시판, 테스트유저_1, "origin-image-url"); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.updatePost( + 테스트유저_1, + testPost.getId(), + request, + imageFiles + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); + } + + @Test + void 이미지를_5개_초과하여_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = createSixImageFiles(); + + // when & then + assertThatThrownBy(() -> + postCommandService.updatePost( + 테스트유저_1, + testPost.getId(), + request, + imageFiles + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); + } + } + + @Nested + class 게시글_삭제_테스트 { + + @Test + void 게시글을_성공적으로_삭제한다() { + // given + String originImageUrl = "origin-image-url"; + Post testPost = createPost(자유게시판, 테스트유저_1, originImageUrl); + String viewCountKey = redisUtils.getPostViewCountRedisKey(testPost.getId()); + redisService.increaseViewCount(viewCountKey); + + // when + PostDeleteResponse response = postCommandService.deletePostById( + 테스트유저_1, + testPost.getId() + ); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(testPost.getId()), + () -> assertThat(postRepository.findById(testPost.getId())).isEmpty(), + () -> assertThat(redisService.isKeyExists(viewCountKey)).isFalse() + ); + then(s3Service).should().deletePostImage(originImageUrl); + } + + @Test + void 다른_사용자의_게시글을_삭제하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + + // when & then + assertThatThrownBy(() -> + postCommandService.deletePostById( + 테스트유저_2, + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + + @Test + void 질문_게시글을_삭제하면_예외_응답을_반환한다() { + // given + Post testPost = createQuestionPost(자유게시판, 테스트유저_1, "origin-image-url"); + + // when & then + assertThatThrownBy(() -> + postCommandService.deletePostById( + 테스트유저_1, + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); + } + } + + private PostCreateRequest createPostCreateRequest(String category) { + return new PostCreateRequest( + 자유게시판.getCode(), + category, + "테스트 제목", + "테스트 내용", + false + ); + } + + private MockMultipartFile createImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + private List createSixImageFiles() { + return List.of( + createImageFile(), + createImageFile(), + createImageFile(), + createImageFile(), + createImageFile(), + createImageFile() + ); + } + + private Post createPost(Board board, SiteUser siteUser, String originImageUrl) { + Post post = new Post( + "원본 제목", + "원본 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage(originImageUrl); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } + + private Post createQuestionPost(Board board, SiteUser siteUser, String originImageUrl) { + Post post = new Post( + "질문 제목", + "질문 내용", + true, + 0L, + 0L, + PostCategory.질문 + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage(originImageUrl); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } + + private PostUpdateRequest createPostUpdateRequest() { + return new PostUpdateRequest( + PostCategory.자유.name(), + "수정된 제목", + "수정된 내용" + ); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java new file mode 100644 index 000000000..23fa6bf50 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java @@ -0,0 +1,132 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostDislikeResponse; +import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.PostCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_LIKE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("게시글 좋아요 서비스 테스트") +class PostLikeServiceTest extends BaseIntegrationTest { + + @Autowired + private PostLikeService postLikeService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostLikeRepository postLikeRepository; + + @Nested + class 게시글_좋아요_테스트 { + + @Test + void 게시글을_성공적으로_좋아요한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + long beforeLikeCount = testPost.getLikeCount(); + + // when + PostLikeResponse response = postLikeService.likePost( + 테스트유저_1, + testPost.getId() + ); + + // then + Post likedPost = postRepository.findById(testPost.getId()).orElseThrow(); + assertAll( + () -> assertThat(response.likeCount()).isEqualTo(beforeLikeCount + 1), + () -> assertThat(response.isLiked()).isTrue(), + () -> assertThat(likedPost.getLikeCount()).isEqualTo(beforeLikeCount + 1), + () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUser(likedPost, 테스트유저_1)).isPresent() + ); + } + + @Test + void 이미_좋아요한_게시글을_다시_좋아요하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + postLikeService.likePost(테스트유저_1, testPost.getId()); + + // when & then + assertThatThrownBy(() -> + postLikeService.likePost( + 테스트유저_1, + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(DUPLICATE_POST_LIKE.getMessage()); + } + } + + @Nested + class 게시글_좋아요_취소_테스트 { + + @Test + void 게시글_좋아요를_성공적으로_취소한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + PostLikeResponse beforeResponse = postLikeService.likePost(테스트유저_1, testPost.getId()); + long beforeLikeCount = beforeResponse.likeCount(); + + // when + PostDislikeResponse response = postLikeService.dislikePost( + 테스트유저_1, + testPost.getId() + ); + + // then + Post unlikedPost = postRepository.findById(testPost.getId()).orElseThrow(); + assertAll( + () -> assertThat(response.likeCount()).isEqualTo(beforeLikeCount - 1), + () -> assertThat(response.isLiked()).isFalse(), + () -> assertThat(unlikedPost.getLikeCount()).isEqualTo(beforeLikeCount - 1), + () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUser(unlikedPost, 테스트유저_1)).isEmpty() + ); + } + + @Test + void 좋아요하지_않은_게시글을_좋아요_취소하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + + // when & then + assertThatThrownBy(() -> + postLikeService.dislikePost( + 테스트유저_1, + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_LIKE.getMessage()); + } + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "테스트 제목", + "테스트 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + return postRepository.save(post); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java new file mode 100644 index 000000000..fc7926698 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java @@ -0,0 +1,179 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostFindPostImageResponse; +import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("게시글 조회 서비스 테스트") +class PostQueryServiceTest extends BaseIntegrationTest { + + @Autowired + private PostQueryService postQueryService; + + @Autowired + private RedisService redisService; + + @Autowired + private RedisUtils redisUtils; + + @Autowired + private PostRepository postRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostImageRepository postImageRepository; + + @Test + void 게시판_코드와_카테고리로_게시글_목록을_조회한다() { + // given + List posts = List.of( + 미주권_자유게시글, 아시아권_자유게시글, 유럽권_자유게시글, 자유게시판_자유게시글, + 미주권_질문게시글, 아시아권_질문게시글, 유럽권_질문게시글, 자유게시판_질문게시글 + ); + List expectedPosts = posts.stream() + .filter(post -> post.getCategory().equals(PostCategory.자유) && post.getBoard().getCode().equals(BoardCode.FREE.name())) + .toList(); + List expectedResponses = PostListResponse.from(expectedPosts); + + // when + List actualResponses = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.자유.name() + ); + + // then + assertThat(actualResponses) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(ZonedDateTime.class) + .isEqualTo(expectedResponses); + } + + @Test + void 전체_카테고리로_조회시_해당_게시판의_모든_게시글을_조회한다() { + // given + List posts = List.of( + 미주권_자유게시글, 아시아권_자유게시글, 유럽권_자유게시글, 자유게시판_자유게시글, + 미주권_질문게시글, 아시아권_질문게시글, 유럽권_질문게시글, 자유게시판_질문게시글 + ); + List expectedPosts = posts.stream() + .filter(post -> post.getBoard().getCode().equals(BoardCode.FREE.name())) + .toList(); + List expectedResponses = PostListResponse.from(expectedPosts); + + // when + List actualResponses = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.전체.name() + ); + + // then + assertThat(actualResponses) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(ZonedDateTime.class) + .isEqualTo(expectedResponses); + } + + @Test + void 게시글을_성공적으로_조회한다() { + // given + String expectedImageUrl = "test-image-url"; + List imageUrls = List.of(expectedImageUrl); + Post testPost = createPost(자유게시판, 테스트유저_1, expectedImageUrl); + List comments = createComments(testPost, 테스트유저_1, List.of("첫번째 댓글", "두번째 댓글")); + + String validateKey = redisUtils.getValidatePostViewCountRedisKey(테스트유저_1.getId(), testPost.getId()); + String viewCountKey = redisUtils.getPostViewCountRedisKey(testPost.getId()); + + // when + PostFindResponse response = postQueryService.findPostById( + 테스트유저_1, + testPost.getId() + ); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(testPost.getId()), + () -> assertThat(response.title()).isEqualTo(testPost.getTitle()), + () -> assertThat(response.content()).isEqualTo(testPost.getContent()), + () -> assertThat(response.isQuestion()).isEqualTo(testPost.getIsQuestion()), + () -> assertThat(response.likeCount()).isEqualTo(testPost.getLikeCount()), + () -> assertThat(response.viewCount()).isEqualTo(testPost.getViewCount()), + () -> assertThat(response.postCategory()).isEqualTo(String.valueOf(testPost.getCategory())), + + () -> assertThat(response.postFindBoardResponse().code()).isEqualTo(자유게시판.getCode()), + () -> assertThat(response.postFindBoardResponse().koreanName()).isEqualTo(자유게시판.getKoreanName()), + + () -> assertThat(response.postFindSiteUserResponse().id()).isEqualTo(테스트유저_1.getId()), + () -> assertThat(response.postFindSiteUserResponse().nickname()).isEqualTo(테스트유저_1.getNickname()), + () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()).isEqualTo(테스트유저_1.getProfileImageUrl()), + + () -> assertThat(response.postFindPostImageResponses()) + .hasSize(imageUrls.size()) + .extracting(PostFindPostImageResponse::url) + .containsExactlyElementsOf(imageUrls), + + () -> assertThat(response.postFindCommentResponses()) + .hasSize(comments.size()) + .extracting(PostFindCommentResponse::content) + .containsExactlyElementsOf(comments.stream().map(Comment::getContent).toList()), + + () -> assertThat(response.isOwner()).isTrue(), + () -> assertThat(response.isLiked()).isFalse(), + + () -> assertThat(redisService.isKeyExists(viewCountKey)).isTrue(), + () -> assertThat(redisService.isKeyExists(validateKey)).isTrue() + ); + } + + private Post createPost(Board board, SiteUser siteUser, String originImageUrl) { + Post post = new Post( + "원본 제목", + "원본 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage(originImageUrl); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } + + private List createComments(Post post, SiteUser siteUser, List contents) { + return contents.stream() + .map(content -> { + Comment comment = new Comment(content); + comment.setPostAndSiteUser(post, siteUser); + return commentRepository.save(comment); + }) + .toList(); + } +} diff --git a/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java new file mode 100644 index 000000000..52b9f24f0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java @@ -0,0 +1,123 @@ +package com.example.solidconnection.concurrency; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.service.PostLikeService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +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.beans.factory.annotation.Value; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@TestContainerSpringBootTest +@DisplayName("게시글 좋아요 동시성 테스트") +class PostLikeCountConcurrencyTest { + + @Autowired + private PostLikeService postLikeService; + @Autowired + private PostRepository postRepository; + @Autowired + private BoardRepository boardRepository; + @Autowired + private SiteUserRepository siteUserRepository; + + @Value("${view.count.scheduling.delay}") + private int SCHEDULING_DELAY_MS; + private int THREAD_NUMS = 1000; + private int THREAD_POOL_SIZE = 200; + private int TIMEOUT_SECONDS = 10; + + private Post post; + private Board board; + private SiteUser siteUser; + + @BeforeEach + void setUp() { + board = createBoard(); + boardRepository.save(board); + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + post = createPost(board, siteUser); + postRepository.save(post); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Board createBoard() { + return new Board( + "FREE", "자유게시판"); + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + post.setBoardAndSiteUser(board, siteUser); + + return post; + } + + @Test + void 게시글_좋아요_동시성_문제를_해결한다() throws InterruptedException { + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + Long likeCount = postRepository.getById(post.getId()).getLikeCount(); + + for (int i = 0; i < THREAD_NUMS; i++) { + String email = "email" + i; + SiteUser tmpSiteUser = siteUserRepository.save(createSiteUserByEmail(email)); + executorService.submit(() -> { + try { + postLikeService.likePost(tmpSiteUser, post.getId()); + postLikeService.dislikePost(tmpSiteUser, post.getId()); + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + + assertEquals(likeCount, postRepository.getById(post.getId()).getLikeCount()); + } +} diff --git a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java new file mode 100644 index 000000000..2cb6eaa27 --- /dev/null +++ b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java @@ -0,0 +1,171 @@ +package com.example.solidconnection.concurrency; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.RedisUtils; +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.beans.factory.annotation.Value; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.type.RedisConstants.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@TestContainerSpringBootTest +@DisplayName("게시글 조회수 동시성 테스트") +public class PostViewCountConcurrencyTest { + + @Autowired + private RedisService redisService; + @Autowired + private PostRepository postRepository; + @Autowired + private BoardRepository boardRepository; + @Autowired + private SiteUserRepository siteUserRepository; + @Autowired + private RedisUtils redisUtils; + + @Value("${view.count.scheduling.delay}") + private int SCHEDULING_DELAY_MS; + private int THREAD_NUMS = 1000; + private int THREAD_POOL_SIZE = 200; + private int TIMEOUT_SECONDS = 10; + + private Post post; + private Board board; + private SiteUser siteUser; + + @BeforeEach + public void setUp() { + board = createBoard(); + boardRepository.save(board); + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + post = createPost(board, siteUser); + postRepository.save(post); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Board createBoard() { + return new Board( + "FREE", "자유게시판"); + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + post.setBoardAndSiteUser(board, siteUser); + + return post; + } + + @Test + public void 게시글을_조회할_때_조회수_동시성_문제를_해결한다() throws InterruptedException { + + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + + Thread.sleep(SCHEDULING_DELAY_MS+1000); + + assertEquals(THREAD_NUMS, postRepository.getById(post.getId()).getViewCount()); + } + + @Test + public void 게시글을_조회할_때_조회수_조작_문제를_해결한다() throws InterruptedException { + + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + if (isFirstTime) { + redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); + } + } finally { + doneSignal.countDown(); + } + }); + } + Thread.sleep(Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()) * 1000); + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + if (isFirstTime) { + redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); + } + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + + Thread.sleep(SCHEDULING_DELAY_MS+1000); + + assertEquals(2L, postRepository.getById(post.getId()).getViewCount()); + } +} diff --git a/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java new file mode 100644 index 000000000..35ab993f5 --- /dev/null +++ b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.concurrency; + +import com.example.solidconnection.application.service.ApplicationQueryService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +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.data.redis.core.RedisTemplate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@TestContainerSpringBootTest +@DisplayName("ThunderingHerd 테스트") +public class ThunderingHerdTest { + @Autowired + private ApplicationQueryService applicationQueryService; + @Autowired + private RedisTemplate redisTemplate; + @Autowired + private SiteUserRepository siteUserRepository; + private int THREAD_NUMS = 1000; + private int THREAD_POOL_SIZE = 200; + private int TIMEOUT_SECONDS = 10; + private SiteUser siteUser; + + @BeforeEach + public void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + @Test + public void ThunderingHerd_문제를_해결한다() throws InterruptedException { + redisTemplate.opsForValue().getAndDelete("application::"); + redisTemplate.opsForValue().getAndDelete("application:ASIA:"); + redisTemplate.opsForValue().getAndDelete("application::추오"); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + List tasks = Arrays.asList( + () -> applicationQueryService.getApplicants(siteUser, "", ""), + () -> applicationQueryService.getApplicants(siteUser, "ASIA", ""), + () -> applicationQueryService.getApplicants(siteUser, "", "추오") + ); + Collections.shuffle(tasks); + tasks.forEach(Runnable::run); + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + } +} diff --git a/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java b/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java new file mode 100644 index 000000000..7e4cae5b2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.support.TestContainerSpringBootTest; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("커스텀 인가 예외 처리 테스트") +class CustomAccessDeniedHandlerTest { + + @Autowired + private CustomAccessDeniedHandler accessDeniedHandler; + + @Autowired + private ObjectMapper objectMapper; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void 권한이_없는_사용자_접근시_403_예외_응답을_반환한다() throws IOException { + // given + AccessDeniedException accessDeniedException = new AccessDeniedException(ACCESS_DENIED.getMessage()); + + // when + accessDeniedHandler.handle(request, response, accessDeniedException); + + // then + ErrorResponse errorResponse = objectMapper.readValue(response.getContentAsString(), ErrorResponse.class); + assertThat(response.getStatus()).isEqualTo(ACCESS_DENIED.getCode()); + assertThat(errorResponse.message()).isEqualTo(ACCESS_DENIED.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java b/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java new file mode 100644 index 000000000..2cef64481 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.support.TestContainerSpringBootTest; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("커스텀 인증 예외 처리 테스트") +class CustomAuthenticationEntryPointTest { + + @Autowired + private CustomAuthenticationEntryPoint authenticationEntryPoint; + + @Autowired + private ObjectMapper objectMapper; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void 인증되지_않은_사용자_접근시_401_예외_응답을_반환한다() throws IOException { + // given + AuthenticationException authException = new AuthenticationServiceException(AUTHENTICATION_FAILED.getMessage()); + + // when + authenticationEntryPoint.commence(request, response, authException); + + // then + ErrorResponse errorResponse = objectMapper.readValue(response.getContentAsString(), ErrorResponse.class); + assertThat(response.getStatus()).isEqualTo(AUTHENTICATION_FAILED.getCode()); + assertThat(errorResponse.message()).isEqualTo(AUTHENTICATION_FAILED.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java b/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java new file mode 100644 index 000000000..779474c27 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java @@ -0,0 +1,111 @@ +package com.example.solidconnection.custom.resolver; + + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@TestContainerSpringBootTest +@DisplayName("인증된 사용자 argument resolver 테스트") +class AuthorizedUserResolverTest { + + @Autowired + private AuthorizedUserResolver authorizedUserResolver; + + @Autowired + private SiteUserRepository siteUserRepository; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void security_context_에_저장된_인증된_사용자를_반환한다() { + // given + SiteUser siteUser = createAndSaveSiteUser(); + Authentication authentication = createAuthenticationWithUser(siteUser); + SecurityContextHolder.getContext().setAuthentication(authentication); + + MethodParameter parameter = mock(MethodParameter.class); + AuthorizedUser authorizedUser = mock(AuthorizedUser.class); + given(parameter.getParameterAnnotation(AuthorizedUser.class)).willReturn(authorizedUser); + given(authorizedUser.required()).willReturn(false); + + // when + SiteUser resolveSiteUser = (SiteUser) authorizedUserResolver.resolveArgument(parameter, null, null, null); + + // then + assertThat(resolveSiteUser).isEqualTo(siteUser); + } + + @Nested + class security_context_에_저장된_사용자가_없는_경우 { + + @Test + void required_가_true_이면_예외_응답을_반환한다() { + // given + MethodParameter parameter = mock(MethodParameter.class); + AuthorizedUser authorizedUser = mock(AuthorizedUser.class); + given(parameter.getParameterAnnotation(AuthorizedUser.class)).willReturn(authorizedUser); + given(authorizedUser.required()).willReturn(true); + + // when, then + assertThatCode(() -> authorizedUserResolver.resolveArgument(parameter, null, null, null)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + + @Test + void required_가_false_이면_null_을_반환한다() { + // given + MethodParameter parameter = mock(MethodParameter.class); + AuthorizedUser authorizedUser = mock(AuthorizedUser.class); + given(parameter.getParameterAnnotation(AuthorizedUser.class)).willReturn(authorizedUser); + given(authorizedUser.required()).willReturn(false); + + // when, then + assertThat( + authorizedUserResolver.resolveArgument(parameter, null, null, null) + ).isNull(); + } + } + + private SiteUser createAndSaveSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private SiteUserAuthentication createAuthenticationWithUser(SiteUser siteUser) { + SiteUserDetails userDetails = new SiteUserDetails(siteUser); + return new SiteUserAuthentication("token", userDetails); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java b/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java new file mode 100644 index 000000000..a0393dbc7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.custom.resolver; + +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.support.TestContainerSpringBootTest; +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.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("만료된 토큰 argument resolver 테스트") +class ExpiredTokenResolverTest { + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Autowired + private ExpiredTokenResolver expiredTokenResolver; + + @Test + void security_context_에_저장된_만료시간을_검증하지_않는_토큰을_반환한다() throws Exception { + // given + ExpiredTokenAuthentication authentication = new ExpiredTokenAuthentication("token"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // when + ExpiredTokenAuthentication expiredTokenAuthentication = (ExpiredTokenAuthentication) expiredTokenResolver.resolveArgument(null, null, null, null); + + // then + assertThat(expiredTokenAuthentication.getToken()).isEqualTo("token"); + } + + @Test + void security_context_에_저장된_만료시간을_검증하지_않는_토큰이_없으면_null_을_반환한다() throws Exception { + // when, then + assertThat(expiredTokenResolver.resolveArgument(null, null, null, null)).isNull(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java b/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java new file mode 100644 index 000000000..9ef78d0c7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java @@ -0,0 +1,64 @@ +package com.example.solidconnection.custom.security.authentication; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("만료된 토큰 인증 정보 테스트") +class ExpiredTokenAuthenticationTest { + + @Test + void 인증_정보에_저장된_토큰을_반환한다() { + // given + String token = "token123"; + ExpiredTokenAuthentication auth = new ExpiredTokenAuthentication(token); + + // when + String result = auth.getToken(); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + void 인증_정보에_저장된_토큰의_subject_를_반환한다() { + // given + String subject = "subject321"; + String token = createToken(subject); + ExpiredTokenAuthentication auth = new ExpiredTokenAuthentication(token, subject); + + // when + String result = auth.getSubject(); + + // then + assertThat(result).isEqualTo(subject); + } + + @Test + void 항상_isAuthenticated_는_false_를_반환한다() { + // given + ExpiredTokenAuthentication auth1 = new ExpiredTokenAuthentication("token"); + ExpiredTokenAuthentication auth2 = new ExpiredTokenAuthentication("token", "subject"); + + // when & then + assertAll( + () -> assertThat(auth1.isAuthenticated()).isFalse(), + () -> assertThat(auth2.isAuthenticated()).isFalse() + ); + } + + private String createToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, "secret") + .compact(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java b/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java new file mode 100644 index 000000000..6932fcd28 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.custom.security.authentication; + +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SiteUserAuthenticationTest { + + @Test + void 인증_정보에_저장된_토큰을_반환한다() { + // given + String token = "token"; + SiteUserAuthentication authentication = new SiteUserAuthentication(token); + + // when + String result = authentication.getToken(); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + void 인증_정보에_저장된_사용자를_반환한다() { + // given + SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); + SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); + + // when & then + SiteUserDetails actual = (SiteUserDetails) authentication.getPrincipal(); + + // then + assertThat(actual) + .extracting("siteUser") + .extracting("id") + .isEqualTo(userDetails.getSiteUser().getId()); + } + + @Test + void 인증_전에_생성되면_isAuthenticated_는_false_를_반환한다() { + // given + SiteUserAuthentication authentication = new SiteUserAuthentication("token"); + + // when & then + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + void 인증_후에_생성되면_isAuthenticated_는_true_를_반환한다() { + // given + SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); + SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); + + // when & then + assertThat(authentication.isAuthenticated()).isTrue(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java new file mode 100644 index 000000000..f1b3c7359 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java @@ -0,0 +1,102 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +class ExceptionHandlerFilterTest { + + @Autowired + private ExceptionHandlerFilter exceptionHandlerFilter; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + @BeforeEach() + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + } + + @Test + void 필터_체인에서_예외가_발생하면_SecurityContext_를_초기화한다() throws Exception { + // given + Authentication authentication = mock(TestingAuthenticationToken.class); + SecurityContextHolder.getContext().setAuthentication(authentication); + willThrow(new RuntimeException()).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void 필터_체인에서_예외가_발생하지_않으면_다음_필터로_진행한다() throws Exception { + // given + willDoNothing().given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + @ParameterizedTest + @MethodSource("provideException") + void 필터_체인에서_예외가_발생하면_예외_응답을_반환한다(Throwable throwable) throws Exception { + // given + willThrow(throwable).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + } + + private static Stream provideException() { + return Stream.of( + new RuntimeException(), + new CustomException(ErrorCode.INVALID_TOKEN) + ); + } + + private Authentication getAnonymousAuth() { + return new AnonymousAuthenticationToken( + "key", + "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS") + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 000000000..cbca9c5f2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,116 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetailsService; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +@DisplayName("토큰 인증 필터 테스트") +class JwtAuthenticationFilterTest { + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Autowired + private JwtProperties jwtProperties; + + @MockBean + private SiteUserDetailsService siteUserDetailsService; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + @BeforeEach() + void setUp() { + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + SecurityContextHolder.clearContext(); + } + + @Test + public void 토큰이_없으면_다음_필터로_진행한다() throws Exception { + // given + request = new MockHttpServletRequest(); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + then(filterChain).should().doFilter(request, response); + } + + @Nested + class 토큰이_있으면_컨텍스트에_저장한다 { + + @Test + void 유효한_토큰을_컨텍스트에_저장한다() throws Exception { + // given + Date validExpiration = new Date(System.currentTimeMillis() + 1000); + String token = createTokenWithExpiration(validExpiration); + request = createRequestWithToken(token); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isExactlyInstanceOf(SiteUserAuthentication.class); + then(filterChain).should().doFilter(request, response); + } + + @Test + void 만료된_토큰을_컨텍스트에_저장한다() throws Exception { + // given + Date invalidExpiration = new Date(System.currentTimeMillis() - 1000); + String token = createTokenWithExpiration(invalidExpiration); + request = createRequestWithToken(token); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isExactlyInstanceOf(ExpiredTokenAuthentication.class); + then(filterChain).should().doFilter(request, response); + } + } + + private String createTokenWithExpiration(Date expiration) { + return Jwts.builder() + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private HttpServletRequest createRequestWithToken(String token) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + return request; + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java new file mode 100644 index 000000000..a11d8d28a --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java @@ -0,0 +1,111 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.data.redis.core.RedisTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Date; +import java.util.Objects; + +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +@DisplayName("로그아웃 체크 필터 테스트") +class SignOutCheckFilterTest { + + @Autowired + private SignOutCheckFilter signOutCheckFilter; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + private final String subject = "subject"; + + @BeforeEach + void setUp() { + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + Objects.requireNonNull(redisTemplate.getConnectionFactory()) + .getConnection() + .serverCommands() + .flushDb(); + } + + @Test + void 로그아웃한_토큰이면_예외를_응답한다() throws Exception { + // given + String token = createToken(subject); + request = createRequest(token); + String refreshTokenKey = BLACKLIST.addPrefix(token); + redisTemplate.opsForValue().set(refreshTokenKey, "signOut"); + + // when & then + assertThatCode(() -> signOutCheckFilter.doFilterInternal(request, response, filterChain)) + .isInstanceOf(CustomException.class) + .hasMessage(USER_ALREADY_SIGN_OUT.getMessage()); + then(filterChain).shouldHaveNoMoreInteractions(); + } + + @Test + void 토큰이_없으면_다음_필터로_전달한다() throws Exception { + // given + request = new MockHttpServletRequest(); + + // when + signOutCheckFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + @Test + void 로그아웃하지_않은_토큰이면_다음_필터로_전달한다() throws Exception { + // given + String token = createToken(subject); + request = createRequest(token); + + // when + signOutCheckFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + private String createToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private HttpServletRequest createRequest(String token) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + return request; + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java b/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java new file mode 100644 index 000000000..ad6053359 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java @@ -0,0 +1,80 @@ +package com.example.solidconnection.custom.security.provider; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; + +import java.net.PasswordAuthentication; +import java.util.Date; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.*; + +@TestContainerSpringBootTest +@DisplayName("만료된 토큰 provider 테스트") +class ExpiredTokenAuthenticationProviderTest { + + @Autowired + private ExpiredTokenAuthenticationProvider expiredTokenAuthenticationProvider; + + @Autowired + private JwtProperties jwtProperties; + + @Test + void 처리할_수_있는_타입인지를_반환한다() { + // given + Class supportedType = ExpiredTokenAuthentication.class; + Class notSupportedType = PasswordAuthentication.class; + + // when & then + assertAll( + () -> assertTrue(expiredTokenAuthenticationProvider.supports(supportedType)), + () -> assertFalse(expiredTokenAuthenticationProvider.supports(notSupportedType)) + ); + } + + @Test + void 만료된_토큰의_인증_정보를_반환한다() { + // given + String expiredToken = createExpiredToken(); + ExpiredTokenAuthentication ExpiredTokenAuthentication = new ExpiredTokenAuthentication(expiredToken); + + // when + Authentication result = expiredTokenAuthenticationProvider.authenticate(ExpiredTokenAuthentication); + + // then + assertAll( + () -> assertThat(result).isInstanceOf(ExpiredTokenAuthentication.class), + () -> assertThat(result.isAuthenticated()).isFalse() + ); + } + + @Test + void 유효하지_않은_토큰이면_예외_응답을_반환한다() { + // given + ExpiredTokenAuthentication ExpiredTokenAuthentication = new ExpiredTokenAuthentication("invalid token"); + + // when & then + assertThatCode(() -> expiredTokenAuthenticationProvider.authenticate(ExpiredTokenAuthentication)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java b/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java new file mode 100644 index 000000000..46d7498a2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java @@ -0,0 +1,159 @@ +package com.example.solidconnection.custom.security.provider; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; + +import java.net.PasswordAuthentication; +import java.util.Date; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("사용자 인증정보 provider 테스트") +class SiteUserAuthenticationProviderTest { + + @Autowired + private SiteUserAuthenticationProvider siteUserAuthenticationProvider; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + } + + @Test + void 처리할_수_있는_타입인지를_반환한다() { + // given + Class supportedType = SiteUserAuthentication.class; + Class notSupportedType = PasswordAuthentication.class; + + // when & then + assertAll( + () -> assertThat(siteUserAuthenticationProvider.supports(supportedType)).isTrue(), + () -> assertThat(siteUserAuthenticationProvider.supports(notSupportedType)).isFalse() + ); + } + + @Test + void 유효한_토큰이면_정상적으로_인증_정보를_반환한다() { + // given + String token = createValidToken(siteUser.getId()); + SiteUserAuthentication auth = new SiteUserAuthentication(token); + + // when + Authentication result = siteUserAuthenticationProvider.authenticate(auth); + + // then + assertThat(result).isNotNull(); + assertAll( + () -> assertThat(result.getCredentials()).isEqualTo(token), + () -> assertThat(result.getPrincipal().getClass()).isEqualTo(SiteUserDetails.class) + ); + } + + @Nested + class 예외_응답을_반환하다 { + + @Test + void 유효하지_않은_토큰이면_예외_응답을_반환한다() { + // given + SiteUserAuthentication expiredAuth = new SiteUserAuthentication(createExpiredToken()); + + // when & then + assertThatCode(() -> siteUserAuthenticationProvider.authenticate(expiredAuth)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + @Test + void 사용자_정보의_형식이_다르면_예외_응답을_반환한다() { + // given + SiteUserAuthentication wrongSubjectTypeAuth = new SiteUserAuthentication(createWrongSubjectTypeToken()); + + // when & then + assertThatCode(() -> siteUserAuthenticationProvider.authenticate(wrongSubjectTypeAuth)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + @Test + void 유효한_토큰이지만_해당되는_사용자가_없으면_예외_응답을_반환한다() { + // given + long notExistingUserId = siteUser.getId() + 100; + String token = createValidToken(notExistingUserId); + SiteUserAuthentication auth = new SiteUserAuthentication(token); + + // when & then + assertThatCode(() -> siteUserAuthenticationProvider.authenticate(auth)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + } + + private String createValidToken(long id) { + return Jwts.builder() + .setSubject(String.valueOf(id)) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject(String.valueOf(siteUser.getId())) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createWrongSubjectTypeToken() { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java new file mode 100644 index 000000000..99e463955 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java @@ -0,0 +1,104 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("사용자 인증 정보 서비스 테스트") +@TestContainerSpringBootTest +class SiteUserDetailsServiceTest { + + @Autowired + private SiteUserDetailsService userDetailsService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Test + void 사용자_인증_정보를_반환한다() { + // given + SiteUser siteUser = siteUserRepository.save(createSiteUser()); + String username = getUserName(siteUser); + + // when + SiteUserDetails userDetails = (SiteUserDetails) userDetailsService.loadUserByUsername(username); + + // then + assertAll( + () -> assertThat(userDetails.getUsername()).isEqualTo(username), + () -> assertThat(userDetails.getSiteUser()).extracting("id").isEqualTo(siteUser.getId()) + ); + } + + @Nested + class 예외_응답을_반환한다 { + + @Test + void 지정되지_않은_형식의_식별자가_주어지면_예외_응답을_반환한다() { + // given + String username = "notNumber"; + + // when & then + assertThatCode(() -> userDetailsService.loadUserByUsername(username)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + @Test + void 식별자에_해당하는_사용자가_없으면_예외_응답을_반환한다() { + // given + String username = "1234"; + + // when & then + assertThatCode(() -> userDetailsService.loadUserByUsername(username)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + + @Test + void 탈퇴한_사용자이면_예외_응답을_반환한다() { + // given + SiteUser siteUser = createSiteUser(); + siteUser.setQuitedAt(LocalDate.now()); + siteUserRepository.save(siteUser); + String username = getUserName(siteUser); + + // when & then + assertThatCode(() -> userDetailsService.loadUserByUsername(username)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private String getUserName(SiteUser siteUser) { + return siteUser.getId().toString(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java new file mode 100644 index 000000000..912072d2b --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("사용자 인증 정보 테스트") +@TestContainerSpringBootTest +class SiteUserDetailsTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Test + void 사용자_권한을_정상적으로_반환한다() { + // given + SiteUser siteUser = siteUserRepository.save(createSiteUser()); + SiteUserDetails siteUserDetails = new SiteUserDetails(siteUser); + + // when + Collection authorities = siteUserDetails.getAuthorities(); + + // then + assertThat(authorities) + .extracting("authority") + .containsExactly("ROLE_" + siteUser.getRole().name()); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java b/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java new file mode 100644 index 000000000..b0267a08b --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.custom.validation.validator; + +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_UNIVERSITY_CHOICE; +import static com.example.solidconnection.custom.exception.ErrorCode.FIRST_CHOICE_REQUIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("대학 선택 유효성 검사 테스트") +class ValidUniversityChoiceValidatorTest { + + private static final String MESSAGE = "message"; + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void 정상적인_지망_선택은_유효하다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, 2L, 3L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void 첫_번째_지망만_선택하는_것은_유효하다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, null, null); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void 두_번째_지망_없이_세_번째_지망을_선택하면_예외_응답을_반환한다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, null, 3L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .extracting(MESSAGE) + .contains(THIRD_CHOICE_REQUIRES_SECOND.getMessage()); + } + + @Test + void 첫_번째_지망을_선택하지_않으면_예외_응답을_반환한다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(null, 2L, 3L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(FIRST_CHOICE_REQUIRED.getMessage()); + } + + @Test + void 대학을_중복_선택하면_예외_응답을_반환한다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, 1L, 2L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(DUPLICATE_UNIVERSITY_CHOICE.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java new file mode 100644 index 000000000..d156cf485 --- /dev/null +++ b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.database; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@Disabled +@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2, replace = AutoConfigureTestDatabase.Replace.ANY) +@ActiveProfiles("test") +@DataJpaTest +class DatabaseConnectionTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + private DatabaseMetaData metaData; + + @DisplayName("데이터베이스 연결 및 테이블 존재 여부 테스트") + @Test + void connectDatabaseAndCheckTables() { + assertThatCode(() -> metaData = Objects.requireNonNull(jdbcTemplate.getDataSource()) + .getConnection() + .getMetaData()) + .doesNotThrowAnyException(); + + assertAll( + () -> assertThat(isTableExist("SITE_USER")).isTrue(), + () -> assertThat(isTableExist("COUNTRY")).isTrue(), + () -> assertThat(isTableExist("INTERESTED_COUNTRY")).isTrue(), + () -> assertThat(isTableExist("REGION")).isTrue(), + () -> assertThat(isTableExist("INTERESTED_REGION")).isTrue(), + () -> assertThat(isTableExist("LANGUAGE_REQUIREMENT")).isTrue(), + () -> assertThat(isTableExist("UNIVERSITY")).isTrue(), + () -> assertThat(isTableExist("LIKED_UNIVERSITY")).isTrue(), + () -> assertThat(isTableExist("UNIVERSITY_INFO_FOR_APPLY")).isTrue(), + () -> assertThat(isTableExist("BOARD")).isTrue(), + () -> assertThat(isTableExist("COMMENT")).isTrue(), + () -> assertThat(isTableExist("POST")).isTrue(), + () -> assertThat(isTableExist("POST_IMAGE")).isTrue(), + () -> assertThat(isTableExist("POST_LIKE")).isTrue() + ); + } + + private boolean isTableExist(String tableName) throws SQLException { + return metaData.getTables(null, null, tableName, null).next(); + } +} diff --git a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java new file mode 100644 index 000000000..69fcedaef --- /dev/null +++ b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.database; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@Disabled +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RedisConnectionTest { + + @Autowired + private RedisTemplate redisTemplate; + + @DisplayName("레디스 연결 및 작동 테스트") + @Test + void connectRedis() { + String key = "test-key"; + String expectedValue = "test-value"; + + redisTemplate.opsForValue().set(key, expectedValue); + String actualValue = redisTemplate.opsForValue().get(key); + + assertThat(actualValue).isEqualTo(expectedValue); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java b/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java new file mode 100644 index 000000000..868eac179 --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java @@ -0,0 +1,334 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.dto.ApplicantResponse; +import com.example.solidconnection.application.dto.ApplicationsResponse; +import com.example.solidconnection.application.dto.UniversityApplicantsResponse; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.VerifyStatus; +import io.restassured.RestAssured; +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.beans.factory.annotation.Value; + +import java.util.List; + +import static com.example.solidconnection.e2e.DynamicFixture.createDummyGpa; +import static com.example.solidconnection.e2e.DynamicFixture.createDummyLanguageTest; +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원자 조회 테스트") +class ApplicantsQueryTest extends UniversityDataSetUpEndToEndTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private AuthTokenProvider authTokenProvider; + + private String accessToken; + private String adminAccessToken; + private String user6AccessToken; + private Application 나의_지원정보; + private Application 사용자1_지원정보; + private Application 사용자2_지원정보; + private Application 사용자3_지원정보; + private Application 사용자4_이전학기_지원정보; + private Application 사용자5_관리자_지원정보; + private Application 사용자6_지원정보; + + @Value("${university.term}") + private String term; + private String beforeTerm = "1988-1"; + + @BeforeEach + public void setUpUserAndToken() { + // setUp - 사용자 정보 저장 + SiteUser 나 = siteUserRepository.save(createSiteUserByEmail("my-email")); + SiteUser 사용자1 = siteUserRepository.save(createSiteUserByEmail("email1")); + SiteUser 사용자2 = siteUserRepository.save(createSiteUserByEmail("email2")); + SiteUser 사용자3 = siteUserRepository.save(createSiteUserByEmail("email3")); + SiteUser 사용자4_이전학기_지원자 = siteUserRepository.save(createSiteUserByEmail("email4")); + SiteUser 사용자5_관리자 = siteUserRepository.save(createSiteUserByEmail("email5")); + SiteUser 사용자6 = siteUserRepository.save(createSiteUserByEmail("email6")); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(나); + authTokenProvider.generateAndSaveRefreshToken(나); + + adminAccessToken = authTokenProvider.generateAccessToken(사용자5_관리자); + authTokenProvider.generateAndSaveRefreshToken(사용자5_관리자); + + user6AccessToken = authTokenProvider.generateAccessToken(사용자6); + authTokenProvider.generateAndSaveRefreshToken(사용자6); + + // setUp - 지원 정보 저장 + Gpa gpa = createDummyGpa(); + LanguageTest languageTest = createDummyLanguageTest(); + 나의_지원정보 = new Application(나, gpa, languageTest, term); + 사용자1_지원정보 = new Application(사용자1, gpa, languageTest, term); + 사용자2_지원정보 = new Application(사용자2, gpa, languageTest, term); + 사용자3_지원정보 = new Application(사용자3, gpa, languageTest, term); + 사용자4_이전학기_지원정보 = new Application(사용자4_이전학기_지원자, gpa, languageTest, beforeTerm); + 사용자5_관리자_지원정보 = new Application(사용자5_관리자, gpa, languageTest, term); + 사용자6_지원정보 = new Application(사용자6, gpa, languageTest, term); + + 나의_지원정보.updateUniversityChoice(괌대학_B_지원_정보, 괌대학_A_지원_정보, 린츠_카톨릭대학_지원_정보, "0"); + 사용자1_지원정보.updateUniversityChoice(괌대학_A_지원_정보, 괌대학_B_지원_정보, 그라츠공과대학_지원_정보, "1"); + 사용자2_지원정보.updateUniversityChoice(메이지대학_지원_정보, 그라츠대학_지원_정보, 서던덴마크대학교_지원_정보, "2"); + 사용자3_지원정보.updateUniversityChoice(네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "3"); + 사용자4_이전학기_지원정보.updateUniversityChoice(네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "4"); + 사용자6_지원정보.updateUniversityChoice(코펜하겐IT대학_지원_정보, null, null, "6"); + 나의_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + 사용자1_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + 사용자2_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + 사용자3_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + 사용자4_이전학기_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + 사용자5_관리자_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + 사용자6_지원정보.setVerifyStatus(VerifyStatus.APPROVED); + applicationRepository.saveAll(List.of(나의_지원정보, 사용자1_지원정보, 사용자2_지원정보, 사용자3_지원정보, 사용자4_이전학기_지원정보, 사용자5_관리자_지원정보, 사용자6_지원정보)); + } + + @Test + void 전체_지원자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().log().all() + .get("/applications") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + List thirdChoiceApplicants = response.thirdChoice(); + + assertThat(firstChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(사용자2_지원정보, false))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(사용자3_지원정보, false))) + )); + assertThat(secondChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(사용자3_지원정보, false))), + UniversityApplicantsResponse.of(그라츠대학_지원_정보, + List.of(ApplicantResponse.of(사용자2_지원정보, false))) + )); + assertThat(thirdChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(서던덴마크대학교_지원_정보, + List.of(ApplicantResponse.of(사용자2_지원정보, false))), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(사용자3_지원정보, false))) + )); + } + + @Test + void 지역으로_필터링해서_지원자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().log().all() + .get("/applications?region=" + 영미권.getCode()) + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + + assertThat(firstChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(사용자3_지원정보, false))))); + assertThat(secondChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))))); + } + + @Test + void 대학_국문_이름으로_필터링해서_지원자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().log().all() + .get("/applications?keyword=라") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + + assertThat(firstChoiceApplicants).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(그라츠대학_지원_정보, List.of()), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, List.of()), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(사용자3_지원정보, false)))); + assertThat(secondChoiceApplicants).containsAnyElementsOf(List.of( + UniversityApplicantsResponse.of(그라츠대학_지원_정보, + List.of(ApplicantResponse.of(사용자2_지원정보, false))), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(사용자3_지원정보, false))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, List.of()))); + } + + @Test + void 국가_국문_이름으로_필터링해서_지원자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().log().all() + .get("/applications?keyword=일본") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + + assertThat(firstChoiceApplicants).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(사용자2_지원정보, false)))); + assertThat(secondChoiceApplicants).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(메이지대학_지원_정보, List.of())); + } + + @Test + void 지원자를_조회할_때_이전학기_지원자는_조회되지_않는다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().log().all() + .get("/applications") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + List thirdChoiceApplicants = response.thirdChoice(); + + + assertThat(firstChoiceApplicants).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(사용자4_이전학기_지원정보, false))) + )); + assertThat(secondChoiceApplicants).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(사용자4_이전학기_지원정보, false))) + )); + assertThat(thirdChoiceApplicants).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(사용자4_이전학기_지원정보, false))) + )); + } + + @Test + void 경쟁자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().log().all() + .get("/applications/competitors") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + Integer choicedUniversityCount = 3; + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + List thirdChoiceApplicants = response.thirdChoice(); + + assertThat(firstChoiceApplicants).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, List.of())); + assertThat(secondChoiceApplicants).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(사용자1_지원정보, false))), + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of())); + assertThat(thirdChoiceApplicants).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of()), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of()), + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of(ApplicantResponse.of(나의_지원정보, true)))); + + assertThat(firstChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + assertThat(secondChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + assertThat(thirdChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + } + + @Test + void 지원_대학중_미선택이_있을_떄_경쟁자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + user6AccessToken) + .when().log().all() + .get("/applications/competitors") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + Integer choicedUniversityCount = 1; + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + List thirdChoiceApplicants = response.thirdChoice(); + + assertThat(firstChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + assertThat(secondChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + assertThat(thirdChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + } + + @Test + void 지원_대학이_모두_미선택일_때_경쟁자를_조회한다() { + ApplicationsResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + adminAccessToken) + .when().log().all() + .get("/applications/competitors") + .then().log().all() + .statusCode(200) + .extract().as(ApplicationsResponse.class); + + Integer choicedUniversityCount = 0; + + List firstChoiceApplicants = response.firstChoice(); + List secondChoiceApplicants = response.secondChoice(); + List thirdChoiceApplicants = response.thirdChoice(); + + assertThat(firstChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + assertThat(secondChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + assertThat(thirdChoiceApplicants.size()).isEqualTo(choicedUniversityCount); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java b/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java new file mode 100644 index 000000000..0b3ac3524 --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.support.DatabaseClearExtension; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.web.server.LocalServerPort; + +@TestContainerSpringBootTest +@ExtendWith(DatabaseClearExtension.class) +abstract class BaseEndToEndTest { + + @LocalServerPort + private int port; + + @BeforeEach + public void setUp() { + RestAssured.port = port; + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java new file mode 100644 index 000000000..09c97d46e --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java @@ -0,0 +1,92 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.type.SemesterAvailableForDispatch; +import com.example.solidconnection.type.TuitionFeeType; +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; + +import java.util.Set; + +public class DynamicFixture { + + public static SiteUser createSiteUserByEmail(String email) { + return new SiteUser( + email, + "nickname", + "profileImage", + "2000-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE + ); + } + + public static SiteUser createSiteUserByNickName(String nickname) { + return new SiteUser( + "email@email.com", + nickname, + "profileImage", + "2000-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE + ); + } + + public static KakaoUserInfoDto createKakaoUserInfoDtoByEmail(String email) { + return new KakaoUserInfoDto( + new KakaoUserInfoDto.KakaoAccountDto( + new KakaoUserInfoDto.KakaoAccountDto.KakaoProfileDto( + "nickname", + "profileImageUrl" + ), + email + ) + ); + } + + public static UniversityInfoForApply createUniversityForApply( + String term, University university, Set languageRequirements) { + return new UniversityInfoForApply( + null, + term, + "koreanName", + 1, + TuitionFeeType.HOME_UNIVERSITY_PAYMENT, + SemesterAvailableForDispatch.ONE_SEMESTER, + "1", + "detailsForLanguage", + "gpaRequirement", + "gpaRequirementCriteria", + "detailsForApply", + "detailsForMajor", + "detailsForAccommodation", + "detailsForEnglishCourse", + "details", + languageRequirements, + university); + } + + public static LikedUniversity createLikedUniversity( + SiteUser siteUser, UniversityInfoForApply universityInfoForApply) { + return new LikedUniversity(null, universityInfoForApply, siteUser); + } + + public static Gpa createDummyGpa() { + return new Gpa(3.5, 4.0, "gpaReportUrl"); + } + + public static LanguageTest createDummyLanguageTest() { + return new LanguageTest(LanguageTestType.TOEIC, "900", "toeicReportUrl"); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/MyPageTest.java b/src/test/java/com/example/solidconnection/e2e/MyPageTest.java new file mode 100644 index 000000000..dd2ce1e3a --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/MyPageTest.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import io.restassured.RestAssured; +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.http.HttpStatus; + +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("마이페이지 테스트") +class MyPageTest extends BaseEndToEndTest { + + private SiteUser siteUser; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private AuthTokenProvider authTokenProvider; + + private String accessToken; + + @BeforeEach + public void setUpUserAndToken() { + // setUp - 회원 정보 저장 + siteUser = siteUserRepository.save(createSiteUserByEmail("email")); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); + } + + @Test + void 마이페이지_정보를_조회한다() { + // request - 요청 + MyPageResponse myPageResponse = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/my") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(MyPageResponse.class); + + assertAll("불러온 마이 페이지 정보가 DB의 정보와 일치한다.", + () -> assertThat(myPageResponse.nickname()).isEqualTo(siteUser.getNickname()), + () -> assertThat(myPageResponse.birth()).isEqualTo(siteUser.getBirth()), + () -> assertThat(myPageResponse.profileImageUrl()).isEqualTo(siteUser.getProfileImageUrl()), + () -> assertThat(myPageResponse.email()).isEqualTo(siteUser.getEmail())); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java new file mode 100644 index 000000000..cc16f71c1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -0,0 +1,137 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.client.KakaoOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; + +import java.time.LocalDate; + +import static com.example.solidconnection.auth.domain.TokenType.REFRESH; +import static com.example.solidconnection.auth.domain.TokenType.SIGN_UP; +import static com.example.solidconnection.e2e.DynamicFixture.createKakaoUserInfoDtoByEmail; +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static com.example.solidconnection.scheduler.UserRemovalScheduler.ACCOUNT_RECOVER_DURATION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +@DisplayName("카카오 로그인 테스트") +class SignInTest extends BaseEndToEndTest { + + @Autowired + SiteUserRepository siteUserRepository; + + @Autowired + RedisTemplate redisTemplate; + + @MockBean + KakaoOAuthClient kakaoOAuthClient; + + @Test + void 신규_회원이_카카오로_로그인한다() { + // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 + String kakaoCode = "kakaoCode"; + String email = "email@email.com"; + KakaoUserInfoDto kakaoUserInfoDto = createKakaoUserInfoDtoByEmail(email); + given(kakaoOAuthClient.getUserInfo(kakaoCode)) + .willReturn(kakaoUserInfoDto); + + // request - body 생성 및 요청 + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + SignUpPrepareResponse response = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(OAuthCodeRequest) + .when().post("/auth/kakao") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(SignUpPrepareResponse.class); + + KakaoUserInfoDto.KakaoAccountDto.KakaoProfileDto kakaoProfileDto = kakaoUserInfoDto.kakaoAccountDto().profile(); + assertAll("카카오톡 사용자 정보를 응답한다.", + () -> assertThat(response.isRegistered()).isFalse(), + () -> assertThat(response.email()).isEqualTo(email), + () -> assertThat(response.nickname()).isEqualTo(kakaoProfileDto.nickname()), + () -> assertThat(response.profileImageUrl()).isEqualTo(kakaoProfileDto.profileImageUrl()), + () -> assertThat(response.signUpToken()).isNotNull()); + assertThat(redisTemplate.opsForValue().get(SIGN_UP.addPrefix(email))) + .as("카카오 인증 토큰을 저장한다.") + .isEqualTo(response.signUpToken()); + } + + @Test + void 기존_회원이_카카오로_로그인한다() { + // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 + String kakaoCode = "kakaoCode"; + String email = "email@email.com"; + given(kakaoOAuthClient.getUserInfo(kakaoCode)) + .willReturn(createKakaoUserInfoDtoByEmail(email)); + + // setUp - 사용자 정보 저장 + SiteUser siteUser = siteUserRepository.save(createSiteUserByEmail(email)); + + // request - body 생성 및 요청 + OAuthCodeRequest oAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + OAuthSignInResponse response = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(oAuthCodeRequest) + .when().post("/auth/kakao") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(OAuthSignInResponse.class); + + assertAll("리프레스 토큰과 엑세스 토큰을 응답한다.", + () -> assertThat(response.isRegistered()).isTrue(), + () -> assertThat(response.accessToken()).isNotNull(), + () -> assertThat(response.refreshToken()).isNotNull()); + assertThat(redisTemplate.opsForValue().get(REFRESH.addPrefix(siteUser.getId().toString()))) + .as("리프레시 토큰을 저장한다.") + .isEqualTo(response.refreshToken()); + } + + @Test + void 탈퇴한_회원이_계정_복구_기간_안에_다시_로그인하면_탈퇴가_무효화된다() { + // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 + String kakaoCode = "kakaoCode"; + String email = "email@email.com"; + given(kakaoOAuthClient.getUserInfo(kakaoCode)) + .willReturn(createKakaoUserInfoDtoByEmail(email)); + + // setUp - 계정 복구 기간이 되지 않은 사용자 저장 + SiteUser siteUserFixture = createSiteUserByEmail(email); + LocalDate justBeforeRemoval = LocalDate.now().minusDays(ACCOUNT_RECOVER_DURATION - 1); + siteUserFixture.setQuitedAt(justBeforeRemoval); + SiteUser siteUser = siteUserRepository.save(siteUserFixture); + + // request - body 생성 및 요청 + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + OAuthSignInResponse response = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(OAuthCodeRequest) + .when().post("/auth/kakao") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(OAuthSignInResponse.class); + + SiteUser updatedSiteUser = siteUserRepository.findById(siteUser.getId()).get(); + assertAll("리프레스 토큰과 엑세스 토큰을 응답하고, 탈퇴 날짜를 초기화한다.", + () -> assertThat(response.isRegistered()).isTrue(), + () -> assertThat(response.accessToken()).isNotNull(), + () -> assertThat(response.refreshToken()).isNotNull(), + () -> assertThat(updatedSiteUser.getQuitedAt()).isNull()); + assertThat(redisTemplate.opsForValue().get(REFRESH.addPrefix(siteUser.getId().toString()))) + .as("리프레시 토큰을 저장한다.") + .isEqualTo(response.refreshToken()); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java new file mode 100644 index 000000000..f6b356178 --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java @@ -0,0 +1,186 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.auth.service.oauth.OAuthSignUpTokenProvider; +import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.entity.Country; +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.entity.Region; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; + +import java.util.List; + +import static com.example.solidconnection.auth.domain.TokenType.REFRESH; +import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByNickName; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("회원가입 테스트") +class SignUpTest extends BaseEndToEndTest { + + @Autowired + SiteUserRepository siteUserRepository; + + @Autowired + CountryRepository countryRepository; + + @Autowired + RegionRepository regionRepository; + + @Autowired + InterestedRegionRepository interestedRegionRepository; + + @Autowired + InterestedCountyRepository interestedCountyRepository; + + @Autowired + AuthTokenProvider authTokenProvider; + + @Autowired + OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + + @Autowired + RedisTemplate redisTemplate; + + @Test + void 유효한_카카오_토큰으로_회원가입한다() { + // setup - 국가, 지역 정보 저장 + Region region = regionRepository.save(new Region("EROUPE", "유럽")); + List countries = countryRepository.saveAll(List.of( + new Country("FR", "프랑스", region), + new Country("DE", "독일", region))); + + // setup - 카카오 토큰 발급 + String email = "email@email.com"; + String generatedKakaoToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); + + // request - body 생성 및 요청 + List interestedRegionNames = List.of("유럽"); + List interestedCountryNames = List.of("프랑스", "독일"); + SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, interestedRegionNames, interestedCountryNames, + PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, "nickname", "2000-01-01"); + SignInResponse response = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(signUpRequest) + .when().post("/auth/sign-up") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(SignInResponse.class); + + SiteUser savedSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO).get(); + assertAll( + "회원 정보를 저장한다.", + () -> assertThat(savedSiteUser.getId()).isNotNull(), + () -> assertThat(savedSiteUser.getEmail()).isEqualTo(email), + () -> assertThat(savedSiteUser.getBirth()).isEqualTo(signUpRequest.birth()), + () -> assertThat(savedSiteUser.getNickname()).isEqualTo(signUpRequest.nickname()), + () -> assertThat(savedSiteUser.getProfileImageUrl()).isEqualTo(signUpRequest.profileImageUrl()), + () -> assertThat(savedSiteUser.getGender()).isEqualTo(signUpRequest.gender()), + () -> assertThat(savedSiteUser.getPreparationStage()).isEqualTo(signUpRequest.preparationStatus())); + + List interestedRegions = interestedRegionRepository.findAllBySiteUser(savedSiteUser).stream() + .map(InterestedRegion::getRegion) + .toList(); + List interestedCountries = interestedCountyRepository.findAllBySiteUser(savedSiteUser).stream() + .map(InterestedCountry::getCountry) + .toList(); + assertAll( + "관심 지역과 나라 정보를 저장한다.", + () -> assertThat(interestedRegions).containsExactlyInAnyOrder(region), + () -> assertThat(interestedCountries).containsExactlyInAnyOrderElementsOf(countries) + ); + + assertThat(redisTemplate.opsForValue().get(REFRESH.addPrefix(savedSiteUser.getId().toString()))) + .as("리프레시 토큰을 저장한다.") + .isEqualTo(response.refreshToken()); + } + + @Test + void 이미_있는_닉네임으로_회원가입하면_예외를_응답한다() { + // setup - 회원 정보 저장 + String alreadyExistNickname = "nickname"; + SiteUser alreadyExistUser = createSiteUserByNickName(alreadyExistNickname); + siteUserRepository.save(alreadyExistUser); + + // setup - 카카오 토큰 발급 + String email = "test@email.com"; + String generatedKakaoToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); + + // request - body 생성 및 요청 + SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, + PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, alreadyExistNickname, "2000-01-01"); + ErrorResponse errorResponse = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(signUpRequest) + .when().post("/auth/sign-up") + .then().log().all() + .statusCode(HttpStatus.CONFLICT.value()) + .extract().as(ErrorResponse.class); + + assertThat(errorResponse.message()) + .isEqualTo(NICKNAME_ALREADY_EXISTED.getMessage()); + } + + @Test + void 이미_있는_이메일로_회원가입하면_예외를_응답한다() { + // setup - 회원 정보 저장 + String alreadyExistEmail = "email@email.com"; + SiteUser alreadyExistUser = createSiteUserByEmail(alreadyExistEmail); + siteUserRepository.save(alreadyExistUser); + + // setup - 카카오 토큰 발급 + String generatedKakaoToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(alreadyExistEmail, AuthType.KAKAO); + + // request - body 생성 및 요청 + SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, + PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, "nickname0", "2000-01-01"); + ErrorResponse errorResponse = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(signUpRequest) + .when().post("/auth/sign-up") + .then().log().all() + .statusCode(HttpStatus.CONFLICT.value()) + .extract().as(ErrorResponse.class); + + assertThat(errorResponse.message()) + .isEqualTo(USER_ALREADY_EXISTED.getMessage()); + } + + @Test + void 유효하지_않은_카카오_토큰으로_회원가입을_하면_예외를_응답한다() { + SignUpRequest signUpRequest = new SignUpRequest("invalid", null, null, + PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, "nickname", "2000-01-01"); + ErrorResponse errorResponse = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(signUpRequest) + .when().post("/auth/sign-up") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract().as(ErrorResponse.class); + + assertThat(errorResponse.message()) + .contains(SIGN_UP_TOKEN_INVALID.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java new file mode 100644 index 000000000..20a0bbc6b --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java @@ -0,0 +1,288 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.entity.Country; +import com.example.solidconnection.entity.Region; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.support.DatabaseClearExtension; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.LanguageRequirementRepository; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.UniversityRepository; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.web.server.LocalServerPort; + +import java.util.HashSet; + +import static com.example.solidconnection.type.SemesterAvailableForDispatch.ONE_SEMESTER; +import static com.example.solidconnection.type.TuitionFeeType.HOME_UNIVERSITY_PAYMENT; + +@ExtendWith(DatabaseClearExtension.class) +@TestContainerSpringBootTest +abstract class UniversityDataSetUpEndToEndTest { + + public static Region 영미권; + public static Region 유럽; + public static Region 아시아; + public static Country 미국; + public static Country 캐나다; + public static Country 덴마크; + public static Country 오스트리아; + public static Country 일본; + + public static University 영미권_미국_괌대학; + public static University 영미권_미국_네바다주립대학_라스베이거스; + public static University 영미권_캐나다_메모리얼대학_세인트존스; + public static University 유럽_덴마크_서던덴마크대학교; + public static University 유럽_덴마크_코펜하겐IT대학; + public static University 유럽_오스트리아_그라츠대학; + public static University 유럽_오스트리아_그라츠공과대학; + public static University 유럽_오스트리아_린츠_카톨릭대학; + public static University 아시아_일본_메이지대학; + + public static UniversityInfoForApply 괌대학_A_지원_정보; + public static UniversityInfoForApply 괌대학_B_지원_정보; + public static UniversityInfoForApply 네바다주립대학_라스베이거스_지원_정보; + public static UniversityInfoForApply 메모리얼대학_세인트존스_A_지원_정보; + public static UniversityInfoForApply 서던덴마크대학교_지원_정보; + public static UniversityInfoForApply 코펜하겐IT대학_지원_정보; + public static UniversityInfoForApply 그라츠대학_지원_정보; + public static UniversityInfoForApply 그라츠공과대학_지원_정보; + public static UniversityInfoForApply 린츠_카톨릭대학_지원_정보; + public static UniversityInfoForApply 메이지대학_지원_정보; + + @Value("${university.term}") + public String term; + + @LocalServerPort + private int port; + + @Autowired + private RegionRepository regionRepository; + + @Autowired + private CountryRepository countryRepository; + + @Autowired + private UniversityRepository universityRepository; + + @Autowired + private UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Autowired + private LanguageRequirementRepository languageRequirementRepository; + + @BeforeEach + public void setUpBasicData() { + RestAssured.port = port; + + 영미권 = regionRepository.save(new Region("AMERICAS", "영미권")); + 유럽 = regionRepository.save(new Region("EUROPE", "유럽")); + 아시아 = regionRepository.save(new Region("ASIA", "아시아")); + + 미국 = countryRepository.save(new Country("US", "미국", 영미권)); + 캐나다 = countryRepository.save(new Country("CA", "캐나다", 영미권)); + 덴마크 = countryRepository.save(new Country("DK", "덴마크", 유럽)); + 오스트리아 = countryRepository.save(new Country("AT", "오스트리아", 유럽)); + 일본 = countryRepository.save(new Country("JP", "일본", 아시아)); + + 영미권_미국_괌대학 = universityRepository.save(new University( + null, "괌대학", "University of Guam", "university_of_guam", + "https://www.uog.edu/admissions/international-students", + "https://www.uog.edu/admissions/course-schedule", + "https://www.uog.edu/life-at-uog/residence-halls/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/1.png", + null, 미국, 영미권 + )); + + 영미권_미국_네바다주립대학_라스베이거스 = universityRepository.save(new University( + null, "네바다주립대학 라스베이거스", "University of Nevada, Las Vegas", "university_of_nevada_las_vegas", + "https://www.unlv.edu/engineering/eip", + "https://www.unlv.edu/engineering/academic-programs", + "https://www.unlv.edu/housing", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/1.png", + null, 미국, 영미권 + )); + + 영미권_캐나다_메모리얼대학_세인트존스 = universityRepository.save(new University( + null, "메모리얼 대학 세인트존스", "Memorial University of Newfoundland St. John's", "memorial_university_of_newfoundland_st_johns", + "https://mun.ca/goabroad/visiting-students-inbound/", + "https://www.unlv.edu/engineering/academic-programs", + "https://www.mun.ca/residences/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/1.png", + null, 캐나다, 영미권 + )); + + 유럽_덴마크_서던덴마크대학교 = universityRepository.save(new University( + null, "서던덴마크대학교", "University of Southern Denmark", "university_of_southern_denmark", + "https://www.sdu.dk/en", + "https://www.sdu.dk/en", + "https://www.sdu.dk/en/uddannelse/information_for_international_students/studenthousing", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/1.png", + null, 덴마크, 유럽 + )); + + 유럽_덴마크_코펜하겐IT대학 = universityRepository.save(new University( + null, "코펜하겐 IT대학", "IT University of Copenhagen", "it_university_of_copenhagen", + "https://en.itu.dk/", null, + "https://en.itu.dk/Programmes/Student-Life/Practical-information-for-international-students", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/1.png", + null, 덴마크, 유럽 + )); + + 유럽_오스트리아_그라츠대학 = universityRepository.save(new University( + null, "그라츠 대학", "University of Graz", "university_of_graz", + "https://www.uni-graz.at/en/", + "https://static.uni-graz.at/fileadmin/veranstaltungen/orientation/documents/incstud_application-courses.pdf", + "https://orientation.uni-graz.at/de/planning-the-arrival/accommodation/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/1.png", + null, 오스트리아, 유럽 + )); + + 유럽_오스트리아_그라츠공과대학 = universityRepository.save(new University( + null, "그라츠공과대학", "Graz University of Technology", "graz_university_of_technology", + "https://www.tugraz.at/en/home", null, + "https://www.tugraz.at/en/studying-and-teaching/studying-internationally/incoming-students-exchange-at-tu-graz/your-stay-at-tu-graz/preparation#c75033", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/1.png", + null, 오스트리아, 유럽 + )); + + 유럽_오스트리아_린츠_카톨릭대학 = universityRepository.save(new University( + null, "린츠 카톨릭 대학교", "Catholic Private University Linz", "catholic_private_university_linz", + "https://ku-linz.at/en", null, + "https://ku-linz.at/en/ku_international/incomings/kulis", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/1.png", + null, 오스트리아, 유럽 + )); + + 아시아_일본_메이지대학 = universityRepository.save(new University( + null, "메이지대학", "Meiji University", "meiji_university", + "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", null, + "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/1.png", + null, 일본, 아시아 + )); + + 괌대학_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "괌대학(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_괌대학 + )); + + 괌대학_B_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "괌대학(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_괌대학 + )); + + 네바다주립대학_라스베이거스_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "네바다주립대학 라스베이거스(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_네바다주립대학_라스베이거스 + )); + + 메모리얼대학_세인트존스_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "메모리얼 대학 세인트존스(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_캐나다_메모리얼대학_세인트존스 + )); + + 서던덴마크대학교_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "서던덴마크대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_덴마크_서던덴마크대학교 + )); + + 코펜하겐IT대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "코펜하겐 IT대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_덴마크_코펜하겐IT대학 + )); + + 그라츠대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "그라츠 대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_그라츠대학 + )); + + 그라츠공과대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "그라츠공과대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_그라츠공과대학 + )); + + 린츠_카톨릭대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "린츠 카톨릭 대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_린츠_카톨릭대학 + )); + + 메이지대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "메이지대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 아시아_일본_메이지대학 + )); + + saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEFL_IBT, "70"); + saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEIC, "900"); + saveLanguageTestRequirement(네바다주립대학_라스베이거스_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(메모리얼대학_세인트존스_A_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(서던덴마크대학교_지원_정보, LanguageTestType.TOEFL_IBT, "70"); + saveLanguageTestRequirement(코펜하겐IT대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(그라츠대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(그라츠공과대학_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(린츠_카톨릭대학_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(메이지대학_지원_정보, LanguageTestType.JLPT, "N2"); + } + + private void saveLanguageTestRequirement( + UniversityInfoForApply universityInfoForApply, LanguageTestType testType, String minScore) { + LanguageRequirement languageRequirement = new LanguageRequirement( + null, + testType, + minScore, + universityInfoForApply); + universityInfoForApply.addLanguageRequirements(languageRequirement); + universityInfoForApplyRepository.save(universityInfoForApply); + languageRequirementRepository.save(languageRequirement); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java new file mode 100644 index 000000000..c7a364ebf --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java @@ -0,0 +1,86 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.dto.LanguageRequirementResponse; +import com.example.solidconnection.university.dto.UniversityDetailResponse; +import io.restassured.RestAssured; +import org.junit.jupiter.api.Assertions; +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.http.HttpStatus; + +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("대학교 상세 조회 테스트") +class UniversityDetailTest extends UniversityDataSetUpEndToEndTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private AuthTokenProvider authTokenProvider; + + private String accessToken; + + @BeforeEach + public void setUpUserAndToken() { + // setUp - 회원 정보 저장 + String email = "email@email.com"; + SiteUser siteUser = createSiteUserByEmail(email); + siteUserRepository.save(siteUser); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); + } + + @Test + void 대학교_정보를_조회한다() { + // request - 요청 + UniversityDetailResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/universities/" + 메이지대학_지원_정보.getId()) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityDetailResponse.class); + + // response - 응답 + Assertions.assertAll( + () -> assertThat(response.id()).isEqualTo(메이지대학_지원_정보.getId()), + () -> assertThat(response.koreanName()).isEqualTo(메이지대학_지원_정보.getKoreanName()), + () -> assertThat(response.englishName()).isEqualTo(아시아_일본_메이지대학.getEnglishName()), + () -> assertThat(response.region()).isEqualTo(아시아_일본_메이지대학.getRegion().getKoreanName()), + () -> assertThat(response.country()).isEqualTo(아시아_일본_메이지대학.getCountry().getKoreanName()), + () -> assertThat(response.languageRequirements()).isEqualTo( + 메이지대학_지원_정보.getLanguageRequirements().stream() + .map(LanguageRequirementResponse::from) + .toList()), + () -> assertThat(response.term()).isEqualTo(메이지대학_지원_정보.getTerm()), + () -> assertThat(response.formatName()).isEqualTo(아시아_일본_메이지대학.getFormatName()), + () -> assertThat(response.homepageUrl()).isEqualTo(아시아_일본_메이지대학.getHomepageUrl()), + () -> assertThat(response.logoImageUrl()).isEqualTo(아시아_일본_메이지대학.getLogoImageUrl()), + () -> assertThat(response.backgroundImageUrl()).isEqualTo(아시아_일본_메이지대학.getBackgroundImageUrl()), + () -> assertThat(response.detailsForLocal()).isEqualTo(아시아_일본_메이지대학.getDetailsForLocal()), + () -> assertThat(response.studentCapacity()).isEqualTo(메이지대학_지원_정보.getStudentCapacity()), + () -> assertThat(response.tuitionFeeType()).isEqualTo(메이지대학_지원_정보.getTuitionFeeType().getKoreanName()), + () -> assertThat(response.semesterAvailableForDispatch()).isEqualTo(메이지대학_지원_정보.getSemesterAvailableForDispatch().getKoreanName()), + () -> assertThat(response.detailsForLanguage()).isEqualTo(메이지대학_지원_정보.getDetailsForLanguage()), + () -> assertThat(response.gpaRequirement()).isEqualTo(메이지대학_지원_정보.getGpaRequirement()), + () -> assertThat(response.gpaRequirementCriteria()).isEqualTo(메이지대학_지원_정보.getGpaRequirementCriteria()), + () -> assertThat(response.semesterRequirement()).isEqualTo(메이지대학_지원_정보.getSemesterRequirement()), + () -> assertThat(response.detailsForApply()).isEqualTo(메이지대학_지원_정보.getDetailsForApply()), + () -> assertThat(response.detailsForMajor()).isEqualTo(메이지대학_지원_정보.getDetailsForMajor()), + () -> assertThat(response.detailsForAccommodation()).isEqualTo(메이지대학_지원_정보.getDetailsForAccommodation()), + () -> assertThat(response.detailsForEnglishCourse()).isEqualTo(메이지대학_지원_정보.getDetailsForEnglishCourse()), + () -> assertThat(response.details()).isEqualTo(메이지대학_지원_정보.getDetails()), + () -> assertThat(response.accommodationUrl()).isEqualTo(아시아_일본_메이지대학.getAccommodationUrl()), + () -> assertThat(response.englishCourseUrl()).isEqualTo(아시아_일본_메이지대학.getEnglishCourseUrl()) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java new file mode 100644 index 000000000..693d6d91b --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java @@ -0,0 +1,123 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.LikeResultResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import io.restassured.RestAssured; +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.http.HttpStatus; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static com.example.solidconnection.e2e.DynamicFixture.createLikedUniversity; +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static com.example.solidconnection.e2e.DynamicFixture.createUniversityForApply; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_SUCCESS_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("대학교 좋아요 테스트") +class UniversityLikeTest extends UniversityDataSetUpEndToEndTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Autowired + private LikedUniversityRepository likedUniversityRepository; + + @Autowired + private AuthTokenProvider authTokenProvider; + + private String accessToken; + private SiteUser siteUser; + + @BeforeEach + public void setUpUserAndToken() { + // setUp - 회원 정보 저장 + siteUser = createSiteUserByEmail("email@email.com"); + siteUserRepository.save(siteUser); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); + } + + @Test + void 좋아요를_한_대학을_조회한다() { + // setUp - 대학교 좋아요 저장 + UniversityInfoForApply differentTermUniversityInfoForApply = + createUniversityForApply(term + " 추가 지원", 영미권_미국_괌대학, null); + universityInfoForApplyRepository.save(differentTermUniversityInfoForApply); + likedUniversityRepository.saveAll(Set.of( + createLikedUniversity(siteUser, 괌대학_A_지원_정보), + createLikedUniversity(siteUser, differentTermUniversityInfoForApply) + )); + + // request - 요청 + List wishUniversities = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/universities/like") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + List wishUniversitiesId = wishUniversities.stream() + .map(UniversityInfoForApplyPreviewResponse::id) + .toList(); + assertThat(wishUniversitiesId) + .as("좋아요한 대학교를 지원 시기와 관계 없이 불러온다.") + .containsExactlyInAnyOrder(괌대학_A_지원_정보.getId(), differentTermUniversityInfoForApply.getId()); + } + + @Test + void 좋아요_하지_않은_대학교에_좋아요를_누른다() { + // request - 요청 + LikeResultResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .post("/universities/" + 괌대학_A_지원_정보.getId() + "/like") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(LikeResultResponse.class); + + Optional likedUniversity + = likedUniversityRepository.findAllBySiteUser_Id(siteUser.getId()).stream().findFirst(); + assertAll("좋아요 누른 대학교를 저장하고 좋아요 성공 응답을 반환한다.", + () -> assertThat(likedUniversity).isPresent(), + () -> assertThat(likedUniversity.get().getId()).isEqualTo(괌대학_A_지원_정보.getId()), + () -> assertThat(response.result()).isEqualTo(LIKE_SUCCESS_MESSAGE) + ); + } + + @Test + void 대학의_좋아요_여부를_조회한다() { + // setUp - 대학교 좋아요 저장 + likedUniversityRepository.save(createLikedUniversity(siteUser, 괌대학_A_지원_정보)); + + // request - 요청 + IsLikeResponse response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .get("/universities/" + 괌대학_A_지원_정보.getId() + "/like") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(IsLikeResponse.class); + + assertThat(response.isLike()).isTrue(); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java new file mode 100644 index 000000000..9939d1a54 --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java @@ -0,0 +1,191 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +import com.example.solidconnection.university.service.GeneralUniversityRecommendService; +import io.restassured.RestAssured; +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.http.HttpStatus; + +import java.util.List; + +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("추천 대학 목록 조회 테스트") +class UniversityRecommendTest extends UniversityDataSetUpEndToEndTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private InterestedCountyRepository interestedCountyRepository; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private GeneralUniversityRecommendService generalUniversityRecommendService; + + private SiteUser siteUser; + private String accessToken; + + @BeforeEach + void setUp() { + // setUp - 회원 정보 저장 + String email = "email@email.com"; + siteUser = siteUserRepository.save(createSiteUserByEmail(email)); + generalUniversityRecommendService.init(); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); + } + + @Test + void 관심_지역을_설정한_사용자의_추천_대학_목록을_조회한다() { + // setUp - 관심 지역 저장 + interestedRegionRepository.save(new InterestedRegion(siteUser, 영미권)); + + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/universities/recommend") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + assertAll( + String.format("관심 지역에 해당하는 학교를 포함한 %d개의 대학 목록을 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .containsOnlyOnceElementsOf(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보) + )) + ); + } + + @Test + void 관심_국가를_설정한_사용자의_추천_대학_목록을_조회한다() { + // setUp - 관심 국가 저장 + interestedCountyRepository.save(new InterestedCountry(siteUser, 덴마크)); + + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/universities/recommend") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + assertAll( + String.format("관심 국가에 해당하는 학교를 포함한 %d개의 대학 목록을 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .containsOnlyOnceElementsOf(List.of( + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )) + ); + } + + @Test + void 관심_지역과_관심_국가를_설정한_사용자의_추천_대학_목록을_조회한다() { + // setUp - 관심 지역과 국가 저장 + interestedRegionRepository.save(new InterestedRegion(siteUser, 영미권)); + interestedCountyRepository.save(new InterestedCountry(siteUser, 덴마크)); + + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/universities/recommend") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + assertAll( + String.format("관심 지역 또는 국가에 해당하는 학교를 포함한 %d개의 대학 목록을 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .containsOnlyOnceElementsOf(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )) + ); + } + + @Test + void 관심_지역_또는_관심_국가를_설정하지_않은_사용자의_추천_대학_목록을_조회한다() { + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/universities/recommend") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + List generalRecommendUniversities + = this.generalUniversityRecommendService.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList(); + assertAll( + String.format("일반 추천 대학 목록 %d개를 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(generalRecommendUniversities) + .containsOnlyOnceElementsOf(response.recommendedUniversities()) + ); + } + + @Test + void 로그인하지_않은_방문객의_추천_대학_목록을_조회한다() { + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .log().all() + .get("/universities/recommend") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + List generalRecommendUniversities + = this.generalUniversityRecommendService.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList(); + assertAll( + String.format("일반 추천 대학 목록 %d개를 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(generalRecommendUniversities) + .containsOnlyOnceElementsOf(response.recommendedUniversities()) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java b/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java new file mode 100644 index 000000000..5d4ff71fd --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java @@ -0,0 +1,166 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import io.restassured.RestAssured; +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 java.util.List; + +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("대학교 검색 테스트") +class UniversitySearchTest extends UniversityDataSetUpEndToEndTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private AuthTokenProvider authTokenProvider; + + private String accessToken; + private SiteUser siteUser; + + @BeforeEach + public void setUpUserAndToken() { + // setUp - 회원 정보 저장 + siteUser = createSiteUserByEmail("email@email.com"); + siteUserRepository.save(siteUser); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); + } + + @Test + void 아무_필터링_없이_전체_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search") + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response).containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(린츠_카톨릭대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Test + void 지역으로_필터링한_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search?region=" + 영미권.getCode()) + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response).containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보) + ); + } + + @Test + void 국가_국문명_또는_대학_국문명으로_필터링한_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search?keyword=라") + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response).containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보) + ); + } + + @Test + void 둘_이상의_국가_국문명_또는_대학_국문명으로_필터링한_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search?keyword=라&keyword=일본") + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response).containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Test + void 어학시험_종류로_필터링한_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search?testType=TOEFL_IBT") + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response).containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보) + ); + } + + @Test + void 어학시험과_시험_성적으로_필터링한_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search?testType=TOEFL_IBT&testScore=70") + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response).containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보) + ); + } + + @Test + void 지역과_어학시험과_시험_성적으로_필터링한_대학을_조회한다() { + // request - 요청 + List response = RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/universities/search?region=EUROPE&testType=TOEFL_IBT&testScore=70") + .then().log().all() + .statusCode(200) + .extract().jsonPath().getList(".", UniversityInfoForApplyPreviewResponse.class); + + assertThat(response) + .containsExactlyInAnyOrder(UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보)); + } +} diff --git a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java new file mode 100644 index 000000000..0617e1c25 --- /dev/null +++ b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java @@ -0,0 +1,224 @@ +package com.example.solidconnection.score.service; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.dto.GpaScoreRequest; +import com.example.solidconnection.score.dto.GpaScoreStatus; +import com.example.solidconnection.score.dto.GpaScoreStatusResponse; +import com.example.solidconnection.score.dto.LanguageTestScoreRequest; +import com.example.solidconnection.score.dto.LanguageTestScoreStatus; +import com.example.solidconnection.score.dto.LanguageTestScoreStatusResponse; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.type.VerifyStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +@DisplayName("점수 서비스 테스트") +class ScoreServiceTest extends BaseIntegrationTest { + + @Autowired + private ScoreService scoreService; + + @Autowired + private GpaScoreRepository gpaScoreRepository; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private LanguageTestScoreRepository languageTestScoreRepository; + + @MockBean + private S3Service s3Service; + + @Test + void GPA_점수_상태를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + List scores = List.of( + createGpaScore(testUser, 3.5, 4.5), + createGpaScore(testUser, 3.8, 4.5) + ); + + // when + GpaScoreStatusResponse response = scoreService.getGpaScoreStatus(testUser); + + // then + assertThat(response.gpaScoreStatusList()) + .hasSize(scores.size()) + .containsExactlyInAnyOrder( + scores.stream() + .map(GpaScoreStatus::from) + .toArray(GpaScoreStatus[]::new) + ); + } + + @Test + void GPA_점수가_없는_경우_빈_리스트를_반환한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + GpaScoreStatusResponse response = scoreService.getGpaScoreStatus(testUser); + + // then + assertThat(response.gpaScoreStatusList()).isEmpty(); + } + + @Test + void 어학_시험_점수_상태를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + List scores = List.of( + createLanguageTestScore(testUser, LanguageTestType.TOEIC, "100"), + createLanguageTestScore(testUser, LanguageTestType.TOEFL_IBT, "7.5") + ); + siteUserRepository.save(testUser); + + // when + LanguageTestScoreStatusResponse response = scoreService.getLanguageTestScoreStatus(testUser); + + // then + assertThat(response.languageTestScoreStatusList()) + .hasSize(scores.size()) + .containsExactlyInAnyOrder( + scores.stream() + .map(LanguageTestScoreStatus::from) + .toArray(LanguageTestScoreStatus[]::new) + ); + } + + @Test + void 어학_시험_점수가_없는_경우_빈_리스트를_반환한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + LanguageTestScoreStatusResponse response = scoreService.getLanguageTestScoreStatus(testUser); + + // then + assertThat(response.languageTestScoreStatusList()).isEmpty(); + } + + @Test + void GPA_점수를_등록한다() { + // given + SiteUser testUser = createSiteUser(); + GpaScoreRequest request = createGpaScoreRequest(); + MockMultipartFile file = createFile(); + String fileUrl = "/gpa-report.pdf"; + given(s3Service.uploadFile(file, ImgType.GPA)).willReturn(new UploadedFileUrlResponse(fileUrl)); + + // when + long scoreId = scoreService.submitGpaScore(testUser, request, file); + GpaScore savedScore = gpaScoreRepository.findById(scoreId).orElseThrow(); + + // then + assertAll( + () -> assertThat(savedScore.getId()).isEqualTo(scoreId), + () -> assertThat(savedScore.getGpa().getGpa()).isEqualTo(request.gpa()), + () -> assertThat(savedScore.getGpa().getGpaCriteria()).isEqualTo(request.gpaCriteria()), + () -> assertThat(savedScore.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING), + () -> assertThat(savedScore.getGpa().getGpaReportUrl()).isEqualTo(fileUrl) + ); + } + + @Test + void 어학_시험_점수를_등록한다() { + // given + SiteUser testUser = createSiteUser(); + LanguageTestScoreRequest request = createLanguageTestScoreRequest(); + MockMultipartFile file = createFile(); + String fileUrl = "/gpa-report.pdf"; + given(s3Service.uploadFile(file, ImgType.LANGUAGE_TEST)).willReturn(new UploadedFileUrlResponse(fileUrl)); + + // when + long scoreId = scoreService.submitLanguageTestScore(testUser, request, file); + LanguageTestScore savedScore = languageTestScoreRepository.findById(scoreId).orElseThrow(); + + // then + assertAll( + () -> assertThat(savedScore.getId()).isEqualTo(scoreId), + () -> assertThat(savedScore.getLanguageTest().getLanguageTestType()).isEqualTo(request.languageTestType()), + () -> assertThat(savedScore.getLanguageTest().getLanguageTestScore()).isEqualTo(request.languageTestScore()), + () -> assertThat(savedScore.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING), + () -> assertThat(savedScore.getLanguageTest().getLanguageTestReportUrl()).isEqualTo(fileUrl) + ); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private GpaScore createGpaScore(SiteUser siteUser, double gpa, double gpaCriteria) { + GpaScore gpaScore = new GpaScore( + new Gpa(gpa, gpaCriteria, "/gpa-report.pdf"), + siteUser + ); + gpaScore.setSiteUser(siteUser); + return gpaScoreRepository.save(gpaScore); + } + + private LanguageTestScore createLanguageTestScore(SiteUser siteUser, LanguageTestType languageTestType, String score) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(languageTestType, score, "/gpa-report.pdf"), + siteUser + ); + languageTestScore.setSiteUser(siteUser); + return languageTestScoreRepository.save(languageTestScore); + } + + private GpaScoreRequest createGpaScoreRequest() { + return new GpaScoreRequest( + 3.5, + 4.5 + ); + } + + private LanguageTestScoreRequest createLanguageTestScoreRequest() { + return new LanguageTestScoreRequest( + LanguageTestType.TOEFL_IBT, + "100" + ); + } + + private MockMultipartFile createFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java new file mode 100644 index 000000000..d3433937a --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.TestContainerDataJpaTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@TestContainerDataJpaTest +class SiteUserRepositoryTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Nested + class 이메일과_인증_유형이_동일한_사용자는_저장할_수_없다 { + + @Test + void 이메일과_인증_유형이_동일한_사용자를_저장하면_예외_응답을_반환한다() { + // given + SiteUser user1 = createSiteUser("email", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", AuthType.KAKAO); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.save(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 이메일이_같더라도_인증_유형이_다른_사용자는_정상_저장한다() { + // given + SiteUser user1 = createSiteUser("email", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", AuthType.APPLE); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.save(user2)) + .doesNotThrowAnyException(); + } + } + + private SiteUser createSiteUser(String email, AuthType authType) { + return new SiteUser( + email, + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE, + authType + ); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java new file mode 100644 index 000000000..c6236aedf --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java @@ -0,0 +1,293 @@ +package com.example.solidconnection.siteuser.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.dto.NicknameUpdateRequest; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.PROFILE_IMAGE_NEEDED; +import static com.example.solidconnection.siteuser.service.SiteUserService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; +import static com.example.solidconnection.siteuser.service.SiteUserService.NICKNAME_LAST_CHANGE_DATE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.then; + +@DisplayName("유저 서비스 테스트") +class SiteUserServiceTest extends BaseIntegrationTest { + + @Autowired + private SiteUserService siteUserService; + + @MockBean + private S3Service s3Service; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private LikedUniversityRepository likedUniversityRepository; + + @Test + void 마이페이지_정보를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + int likedUniversityCount = createLikedUniversities(testUser); + + // when + MyPageResponse response = siteUserService.getMyPageInfo(testUser); + + // then + Assertions.assertAll( + () -> assertThat(response.nickname()).isEqualTo(testUser.getNickname()), + () -> assertThat(response.profileImageUrl()).isEqualTo(testUser.getProfileImageUrl()), + () -> assertThat(response.role()).isEqualTo(testUser.getRole()), + () -> assertThat(response.authType()).isEqualTo(testUser.getAuthType()), + () -> assertThat(response.birth()).isEqualTo(testUser.getBirth()), + () -> assertThat(response.email()).isEqualTo(testUser.getEmail()), + () -> assertThat(response.likedPostCount()).isEqualTo(testUser.getPostLikeList().size()), + () -> assertThat(response.likedUniversityCount()).isEqualTo(likedUniversityCount) + ); + } + + @Test + void 관심_대학교_목록을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + int likedUniversityCount = createLikedUniversities(testUser); + + // when + List response = siteUserService.getWishUniversity(testUser); + + // then + assertThat(response) + .hasSize(likedUniversityCount) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("id") + .containsAll(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )); + } + + @Nested + class 프로필_이미지_수정_테스트 { + + @Test + void 새로운_이미지로_성공적으로_업데이트한다() { + // given + SiteUser testUser = createSiteUser(); + String expectedUrl = "newProfileImageUrl"; + MockMultipartFile imageFile = createValidImageFile(); + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse(expectedUrl)); + + // when + siteUserService.updateMyPageInfo(testUser, imageFile, "newNickname"); + + // then + assertThat(testUser.getProfileImageUrl()).isEqualTo(expectedUrl); + } + + @Test + void 프로필을_처음_수정하는_것이면_이전_이미지를_삭제하지_않는다() { + // given + SiteUser testUser = createSiteUser(); + MockMultipartFile imageFile = createValidImageFile(); + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); + + // when + siteUserService.updateMyPageInfo(testUser, imageFile, "newNickname"); + + // then + then(s3Service).should(never()).deleteExProfile(any()); + } + + @Test + void 프로필을_처음_수정하는_것이_아니라면_이전_이미지를_삭제한다() { + // given + SiteUser testUser = createSiteUserWithCustomProfile(); + MockMultipartFile imageFile = createValidImageFile(); + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); + + // when + siteUserService.updateMyPageInfo(testUser, imageFile, "newNickname"); + + // then + then(s3Service).should().deleteExProfile(testUser); + } + + @Test + void 빈_이미지_파일로_프로필을_수정하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + MockMultipartFile emptyFile = createEmptyImageFile(); + + // when & then + assertThatCode(() -> siteUserService.updateMyPageInfo(testUser, emptyFile, "newNickname")) + .isInstanceOf(CustomException.class) + .hasMessage(PROFILE_IMAGE_NEEDED.getMessage()); + } + } + + @Nested + class 닉네임_수정_테스트 { + + @BeforeEach + void setUp() { + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); + } + + @Test + void 닉네임을_성공적으로_수정한다() { + // given + SiteUser testUser = createSiteUser(); + MockMultipartFile imageFile = createValidImageFile(); + String newNickname = "newNickname"; + + // when + siteUserService.updateMyPageInfo(testUser, imageFile, newNickname); + + // then + SiteUser updatedUser = siteUserRepository.findById(testUser.getId()).get(); + assertThat(updatedUser.getNicknameModifiedAt()).isNotNull(); + assertThat(updatedUser.getNickname()).isEqualTo(newNickname); + } + + @Test + void 중복된_닉네임으로_변경하면_예외_응답을_반환한다() { + // given + createDuplicatedSiteUser(); + SiteUser testUser = createSiteUser(); + MockMultipartFile imageFile = createValidImageFile(); + + // when & then + assertThatCode(() -> siteUserService.updateMyPageInfo(testUser, imageFile, "duplicatedNickname")) + .isInstanceOf(CustomException.class) + .hasMessage(NICKNAME_ALREADY_EXISTED.getMessage()); + } + + @Test + void 최소_대기기간이_지나지_않은_상태에서_변경하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + MockMultipartFile imageFile = createValidImageFile(); + LocalDateTime modifiedAt = LocalDateTime.now().minusDays(MIN_DAYS_BETWEEN_NICKNAME_CHANGES - 1); + testUser.setNicknameModifiedAt(modifiedAt); + siteUserRepository.save(testUser); + + NicknameUpdateRequest request = new NicknameUpdateRequest("newNickname"); + + // when & then + assertThatCode(() -> siteUserService.updateMyPageInfo(testUser, imageFile, "nickname12")) + .isInstanceOf(CustomException.class) + .hasMessage(createExpectedErrorMessage(modifiedAt)); + } + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private SiteUser createSiteUserWithCustomProfile() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profile/profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private void createDuplicatedSiteUser() { + SiteUser siteUser = new SiteUser( + "duplicated@example.com", + "duplicatedNickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + siteUserRepository.save(siteUser); + } + + private int createLikedUniversities(SiteUser testUser) { + LikedUniversity likedUniversity1 = new LikedUniversity(null, 괌대학_A_지원_정보, testUser); + LikedUniversity likedUniversity2 = new LikedUniversity(null, 메이지대학_지원_정보, testUser); + LikedUniversity likedUniversity3 = new LikedUniversity(null, 코펜하겐IT대학_지원_정보, testUser); + + likedUniversityRepository.save(likedUniversity1); + likedUniversityRepository.save(likedUniversity2); + likedUniversityRepository.save(likedUniversity3); + return likedUniversityRepository.countBySiteUser_Id(testUser.getId()); + } + + private MockMultipartFile createValidImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + private MockMultipartFile createEmptyImageFile() { + return new MockMultipartFile( + "image", + "empty.jpg", + "image/jpeg", + new byte[0] + ); + } + + private String createExpectedErrorMessage(LocalDateTime modifiedAt) { + String formatLastModifiedAt = String.format( + "(마지막 수정 시간 : %s)", + NICKNAME_LAST_CHANGE_DATE_FORMAT.format(modifiedAt) + ); + return CAN_NOT_CHANGE_NICKNAME_YET.getMessage() + " : " + formatLastModifiedAt; + } +} diff --git a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java new file mode 100644 index 000000000..bb77f82f2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +@ActiveProfiles("test") +@Component +public class DatabaseCleaner { + + @Autowired + private RedisTemplate redisTemplate; + + @PersistenceContext + private EntityManager em; + + @Transactional + public void clear() { + em.clear(); + truncate(); + Objects.requireNonNull(redisTemplate.getConnectionFactory()) + .getConnection() + .serverCommands() + .flushDb(); + } + + private void truncate() { + em.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); + getTruncateQueries().forEach(query -> em.createNativeQuery(query).executeUpdate()); + em.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); + } + + @SuppressWarnings("unchecked") + private List getTruncateQueries() { + String sql = """ + SELECT CONCAT('TRUNCATE TABLE ', TABLE_NAME, ';') AS q + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_TYPE = 'BASE TABLE' + """; + + return em.createNativeQuery(sql).getResultList(); + } +} diff --git a/src/test/java/com/example/solidconnection/support/DatabaseClearExtension.java b/src/test/java/com/example/solidconnection/support/DatabaseClearExtension.java new file mode 100644 index 000000000..32d7a8975 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/DatabaseClearExtension.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.support; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseClearExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext context) { + DatabaseCleaner databaseCleaner = getDataCleaner(context); + databaseCleaner.clear(); + } + + private DatabaseCleaner getDataCleaner(ExtensionContext extensionContext) { + return SpringExtension.getApplicationContext(extensionContext) + .getBean(DatabaseCleaner.class); + } +} diff --git a/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java new file mode 100644 index 000000000..0256fec13 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.support; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; + +import javax.sql.DataSource; + +@TestConfiguration +public class MySQLTestContainer { + + @Container + private static final MySQLContainer CONTAINER = new MySQLContainer<>("mysql:8.0"); + + @Bean + public DataSource dataSource() { + return DataSourceBuilder.create() + .url(CONTAINER.getJdbcUrl()) + .username(CONTAINER.getUsername()) + .password(CONTAINER.getPassword()) + .driverClassName(CONTAINER.getDriverClassName()) + .build(); + } + + @PostConstruct + void startContainer() { + if (!CONTAINER.isRunning()) { + CONTAINER.start(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/support/RedisTestContainer.java b/src/test/java/com/example/solidconnection/support/RedisTestContainer.java new file mode 100644 index 000000000..39f35c2d5 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/RedisTestContainer.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.support; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; + +@TestConfiguration +public class RedisTestContainer { + + @Container + private static final GenericContainer CONTAINER = new GenericContainer<>("redis:7.0"); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.redis.host", CONTAINER::getHost); + registry.add("spring.redis.port", CONTAINER::getFirstMappedPort); + } + + @PostConstruct + void startContainer() { + if (!CONTAINER.isRunning()) { + CONTAINER.start(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java new file mode 100644 index 000000000..339672e60 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.support; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import(MySQLTestContainer.class) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface TestContainerDataJpaTest { +} diff --git a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java new file mode 100644 index 000000000..fe9b74f60 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.support; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@ExtendWith({DatabaseClearExtension.class}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({MySQLTestContainer.class, RedisTestContainer.class}) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface TestContainerSpringBootTest { +} diff --git a/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java b/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java new file mode 100644 index 000000000..989e0bc31 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java @@ -0,0 +1,544 @@ +package com.example.solidconnection.support.integration; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.entity.Country; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.entity.Region; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.DatabaseClearExtension; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.LanguageRequirementRepository; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.UniversityRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashSet; +import java.util.List; + +import static com.example.solidconnection.type.BoardCode.AMERICAS; +import static com.example.solidconnection.type.BoardCode.ASIA; +import static com.example.solidconnection.type.BoardCode.EUROPE; +import static com.example.solidconnection.type.BoardCode.FREE; +import static com.example.solidconnection.type.SemesterAvailableForDispatch.ONE_SEMESTER; +import static com.example.solidconnection.type.TuitionFeeType.HOME_UNIVERSITY_PAYMENT; + +@TestContainerSpringBootTest +@ExtendWith(DatabaseClearExtension.class) +public abstract class BaseIntegrationTest { + + public static SiteUser 테스트유저_1; + public static SiteUser 테스트유저_2; + public static SiteUser 테스트유저_3; + public static SiteUser 테스트유저_4; + public static SiteUser 테스트유저_5; + public static SiteUser 테스트유저_6; + public static SiteUser 테스트유저_7; + public static SiteUser 이전학기_지원자; + + public static Region 영미권; + public static Region 유럽; + public static Region 아시아; + public static Country 미국; + public static Country 캐나다; + public static Country 덴마크; + public static Country 오스트리아; + public static Country 일본; + + public static University 영미권_미국_괌대학; + public static University 영미권_미국_네바다주립대학_라스베이거스; + public static University 영미권_캐나다_메모리얼대학_세인트존스; + public static University 유럽_덴마크_서던덴마크대학교; + public static University 유럽_덴마크_코펜하겐IT대학; + public static University 유럽_오스트리아_그라츠대학; + public static University 유럽_오스트리아_그라츠공과대학; + public static University 유럽_오스트리아_린츠_카톨릭대학; + public static University 아시아_일본_메이지대학; + + public static UniversityInfoForApply 괌대학_A_지원_정보; + public static UniversityInfoForApply 괌대학_B_지원_정보; + public static UniversityInfoForApply 네바다주립대학_라스베이거스_지원_정보; + public static UniversityInfoForApply 메모리얼대학_세인트존스_A_지원_정보; + public static UniversityInfoForApply 서던덴마크대학교_지원_정보; + public static UniversityInfoForApply 코펜하겐IT대학_지원_정보; + public static UniversityInfoForApply 그라츠대학_지원_정보; + public static UniversityInfoForApply 그라츠공과대학_지원_정보; + public static UniversityInfoForApply 린츠_카톨릭대학_지원_정보; + public static UniversityInfoForApply 메이지대학_지원_정보; + + public static Application 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서; + public static Application 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서; + public static Application 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서; + public static Application 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서; + public static Application 테스트유저_6_X_X_X_지원서; + public static Application 테스트유저_7_코펜하겐IT대학_X_X_지원서; + public static Application 이전학기_지원서; + + public static Board 미주권; + public static Board 아시아권; + public static Board 유럽권; + public static Board 자유게시판; + + public static Post 미주권_자유게시글; + public static Post 아시아권_자유게시글; + public static Post 유럽권_자유게시글; + public static Post 자유게시판_자유게시글; + public static Post 미주권_질문게시글; + public static Post 아시아권_질문게시글; + public static Post 유럽권_질문게시글; + public static Post 자유게시판_질문게시글; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private RegionRepository regionRepository; + + @Autowired + private CountryRepository countryRepository; + + @Autowired + private UniversityRepository universityRepository; + + @Autowired + private UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Autowired + private LanguageRequirementRepository languageRequirementRepository; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private GpaScoreRepository gpaScoreRepository; + + @Autowired + private LanguageTestScoreRepository languageTestScoreRepository; + + @Autowired + private BoardRepository boardRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostImageRepository postImageRepository; + + @Value("${university.term}") + public String term; + + @BeforeEach + public void setUpBaseData() { + setUpSiteUsers(); + setUpRegions(); + setUpCountries(); + setUpUniversities(); + setUpUniversityInfos(); + setUpLanguageRequirements(); + setUpApplications(); + setUpBoards(); + setUpPosts(); + } + + private void setUpSiteUsers() { + 테스트유저_1 = siteUserRepository.save(new SiteUser( + "test1@example.com", + "nickname1", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + + 테스트유저_2 = siteUserRepository.save(new SiteUser( + "test2@example.com", + "nickname2", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 테스트유저_3 = siteUserRepository.save(new SiteUser( + "test3@example.com", + "nickname3", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + + 테스트유저_4 = siteUserRepository.save(new SiteUser( + "test4@example.com", + "nickname4", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 테스트유저_5 = siteUserRepository.save(new SiteUser( + "test5@example.com", + "nickname5", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + + 테스트유저_6 = siteUserRepository.save(new SiteUser( + "test6@example.com", + "nickname6", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 테스트유저_7 = siteUserRepository.save(new SiteUser( + "test7@example.com", + "nickname7", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 이전학기_지원자 = siteUserRepository.save(new SiteUser( + "old@example.com", + "oldNickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + } + + private void setUpRegions() { + 영미권 = regionRepository.save(new Region("AMERICAS", "영미권")); + 유럽 = regionRepository.save(new Region("EUROPE", "유럽")); + 아시아 = regionRepository.save(new Region("ASIA", "아시아")); + } + + private void setUpCountries() { + 미국 = countryRepository.save(new Country("US", "미국", 영미권)); + 캐나다 = countryRepository.save(new Country("CA", "캐나다", 영미권)); + 덴마크 = countryRepository.save(new Country("DK", "덴마크", 유럽)); + 오스트리아 = countryRepository.save(new Country("AT", "오스트리아", 유럽)); + 일본 = countryRepository.save(new Country("JP", "일본", 아시아)); + } + + private void setUpUniversities() { + 영미권_미국_괌대학 = universityRepository.save(new University( + null, "괌대학", "University of Guam", "university_of_guam", + "https://www.uog.edu/admissions/international-students", + "https://www.uog.edu/admissions/course-schedule", + "https://www.uog.edu/life-at-uog/residence-halls/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/1.png", + null, 미국, 영미권 + )); + + 영미권_미국_네바다주립대학_라스베이거스 = universityRepository.save(new University( + null, "네바다주립대학 라스베이거스", "University of Nevada, Las Vegas", "university_of_nevada_las_vegas", + "https://www.unlv.edu/engineering/eip", + "https://www.unlv.edu/engineering/academic-programs", + "https://www.unlv.edu/housing", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/1.png", + null, 미국, 영미권 + )); + + 영미권_캐나다_메모리얼대학_세인트존스 = universityRepository.save(new University( + null, "메모리얼 대학 세인트존스", "Memorial University of Newfoundland St. John's", "memorial_university_of_newfoundland_st_johns", + "https://mun.ca/goabroad/visiting-students-inbound/", + "https://www.unlv.edu/engineering/academic-programs", + "https://www.mun.ca/residences/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/1.png", + null, 캐나다, 영미권 + )); + + 유럽_덴마크_서던덴마크대학교 = universityRepository.save(new University( + null, "서던덴마크대학교", "University of Southern Denmark", "university_of_southern_denmark", + "https://www.sdu.dk/en", + "https://www.sdu.dk/en", + "https://www.sdu.dk/en/uddannelse/information_for_international_students/studenthousing", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/1.png", + null, 덴마크, 유럽 + )); + + 유럽_덴마크_코펜하겐IT대학 = universityRepository.save(new University( + null, "코펜하겐 IT대학", "IT University of Copenhagen", "it_university_of_copenhagen", + "https://en.itu.dk/", null, + "https://en.itu.dk/Programmes/Student-Life/Practical-information-for-international-students", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/1.png", + null, 덴마크, 유럽 + )); + + 유럽_오스트리아_그라츠대학 = universityRepository.save(new University( + null, "그라츠 대학", "University of Graz", "university_of_graz", + "https://www.uni-graz.at/en/", + "https://static.uni-graz.at/fileadmin/veranstaltungen/orientation/documents/incstud_application-courses.pdf", + "https://orientation.uni-graz.at/de/planning-the-arrival/accommodation/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/1.png", + null, 오스트리아, 유럽 + )); + + 유럽_오스트리아_그라츠공과대학 = universityRepository.save(new University( + null, "그라츠공과대학", "Graz University of Technology", "graz_university_of_technology", + "https://www.tugraz.at/en/home", null, + "https://www.tugraz.at/en/studying-and-teaching/studying-internationally/incoming-students-exchange-at-tu-graz/your-stay-at-tu-graz/preparation#c75033", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/1.png", + null, 오스트리아, 유럽 + )); + + 유럽_오스트리아_린츠_카톨릭대학 = universityRepository.save(new University( + null, "린츠 카톨릭 대학교", "Catholic Private University Linz", "catholic_private_university_linz", + "https://ku-linz.at/en", null, + "https://ku-linz.at/en/ku_international/incomings/kulis", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/1.png", + null, 오스트리아, 유럽 + )); + + 아시아_일본_메이지대학 = universityRepository.save(new University( + null, "메이지대학", "Meiji University", "meiji_university", + "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", null, + "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/1.png", + null, 일본, 아시아 + )); + } + + private void setUpUniversityInfos() { + 괌대학_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "괌대학(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_괌대학 + )); + + 괌대학_B_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "괌대학(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_괌대학 + )); + + 네바다주립대학_라스베이거스_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "네바다주립대학 라스베이거스(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_네바다주립대학_라스베이거스 + )); + + 메모리얼대학_세인트존스_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "메모리얼 대학 세인트존스(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_캐나다_메모리얼대학_세인트존스 + )); + + 서던덴마크대학교_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "서던덴마크대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_덴마크_서던덴마크대학교 + )); + + 코펜하겐IT대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "코펜하겐 IT대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_덴마크_코펜하겐IT대학 + )); + + 그라츠대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "그라츠 대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_그라츠대학 + )); + + 그라츠공과대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "그라츠공과대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_그라츠공과대학 + )); + + 린츠_카톨릭대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "린츠 카톨릭 대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_린츠_카톨릭대학 + )); + + 메이지대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "메이지대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 아시아_일본_메이지대학 + )); + } + + private void setUpLanguageRequirements() { + saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEFL_IBT, "70"); + saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEIC, "900"); + saveLanguageTestRequirement(네바다주립대학_라스베이거스_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(메모리얼대학_세인트존스_A_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(서던덴마크대학교_지원_정보, LanguageTestType.TOEFL_IBT, "70"); + saveLanguageTestRequirement(코펜하겐IT대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(그라츠대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(그라츠공과대학_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(린츠_카톨릭대학_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(메이지대학_지원_정보, LanguageTestType.JLPT, "N2"); + } + + private void setUpApplications() { + 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서 = new Application(테스트유저_2, createApprovedGpaScore(테스트유저_2).getGpa(), createApprovedLanguageTestScore(테스트유저_2).getLanguageTest(), + term, 괌대학_B_지원_정보, 괌대학_A_지원_정보, 린츠_카톨릭대학_지원_정보, "user2_nickname"); + + 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서 = new Application(테스트유저_3, createApprovedGpaScore(테스트유저_3).getGpa(), createApprovedLanguageTestScore(테스트유저_3).getLanguageTest(), + term, 괌대학_A_지원_정보, 괌대학_B_지원_정보, 그라츠공과대학_지원_정보, "user3_nickname"); + + 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서 = new Application(테스트유저_4, createApprovedGpaScore(테스트유저_4).getGpa(), createApprovedLanguageTestScore(테스트유저_4).getLanguageTest(), + term, 메이지대학_지원_정보, 그라츠대학_지원_정보, 서던덴마크대학교_지원_정보, "user4_nickname"); + + 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서 = new Application(테스트유저_5, createApprovedGpaScore(테스트유저_5).getGpa(), createApprovedLanguageTestScore(테스트유저_5).getLanguageTest(), + term, 네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "user5_nickname"); + + 테스트유저_6_X_X_X_지원서 = new Application(테스트유저_6, createApprovedGpaScore(테스트유저_6).getGpa(), createApprovedLanguageTestScore(테스트유저_6).getLanguageTest(), + term, null, null, null, "user6_nickname"); + + 테스트유저_7_코펜하겐IT대학_X_X_지원서 = new Application(테스트유저_7, createApprovedGpaScore(테스트유저_7).getGpa(), createApprovedLanguageTestScore(테스트유저_7).getLanguageTest(), + term, 코펜하겐IT대학_지원_정보, null, null, "user7_nickname"); + + 이전학기_지원서 = new Application(이전학기_지원자, createApprovedGpaScore(이전학기_지원자).getGpa(), createApprovedLanguageTestScore(이전학기_지원자).getLanguageTest(), + "1988-1", 네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "old_nickname"); + + 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_6_X_X_X_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_7_코펜하겐IT대학_X_X_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 이전학기_지원서.setVerifyStatus(VerifyStatus.APPROVED); + + applicationRepository.saveAll(List.of( + 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, + 테스트유저_6_X_X_X_지원서, 테스트유저_7_코펜하겐IT대학_X_X_지원서, 이전학기_지원서)); + } + + private void setUpBoards() { + 미주권 = boardRepository.save(new Board(AMERICAS.name(), "미주권")); + 아시아권 = boardRepository.save(new Board(ASIA.name(), "아시아권")); + 유럽권 = boardRepository.save(new Board(EUROPE.name(), "유럽권")); + 자유게시판 = boardRepository.save(new Board(FREE.name(), "자유게시판")); + } + + private void setUpPosts() { + 미주권_자유게시글 = createPost(미주권, 테스트유저_1, "미주권 자유게시글", "미주권 자유게시글 내용", PostCategory.자유); + 아시아권_자유게시글 = createPost(아시아권, 테스트유저_2, "아시아권 자유게시글", "아시아권 자유게시글 내용", PostCategory.자유); + 유럽권_자유게시글 = createPost(유럽권, 테스트유저_1, "유럽권 자유게시글", "유럽권 자유게시글 내용", PostCategory.자유); + 자유게시판_자유게시글 = createPost(자유게시판, 테스트유저_2, "자유게시판 자유게시글", "자유게시판 자유게시글 내용", PostCategory.자유); + 미주권_질문게시글 = createPost(미주권, 테스트유저_1, "미주권 질문게시글", "미주권 질문게시글 내용", PostCategory.질문); + 아시아권_질문게시글 = createPost(아시아권, 테스트유저_2, "아시아권 질문게시글", "아시아권 질문게시글 내용", PostCategory.질문); + 유럽권_질문게시글 = createPost(유럽권, 테스트유저_1, "유럽권 질문게시글", "유럽권 질문게시글 내용", PostCategory.질문); + 자유게시판_질문게시글 = createPost(자유게시판, 테스트유저_2, "자유게시판 질문게시글", "자유게시판 질문게시글 내용", PostCategory.질문); + } + + private void saveLanguageTestRequirement( + UniversityInfoForApply universityInfoForApply, + LanguageTestType testType, + String minScore + ) { + LanguageRequirement languageRequirement = new LanguageRequirement( + null, + testType, + minScore, + universityInfoForApply); + universityInfoForApply.addLanguageRequirements(languageRequirement); + universityInfoForApplyRepository.save(universityInfoForApply); + languageRequirementRepository.save(languageRequirement); + } + + private GpaScore createApprovedGpaScore(SiteUser siteUser) { + GpaScore gpaScore = new GpaScore( + new Gpa(4.0, 4.5, "/gpa-report.pdf"), + siteUser + ); + gpaScore.setVerifyStatus(VerifyStatus.APPROVED); + return gpaScoreRepository.save(gpaScore); + } + + private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), + siteUser + ); + languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); + return languageTestScoreRepository.save(languageTestScore); + } + + private Post createPost (Board board, SiteUser siteUser, String title, String content, PostCategory category){ + Post post = new Post( + title, + content, + false, + 0L, + 0L, + category + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage("imageUrl"); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java new file mode 100644 index 000000000..d93765a44 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.util.List; + +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("공통 추천 대학 서비스 테스트") +@TestContainerSpringBootTest +class GeneralUniversityRecommendServiceTest extends BaseIntegrationTest { + + @Autowired + private GeneralUniversityRecommendService generalUniversityRecommendService; + + @Value("${university.term}") + private String term; + + @Test + void 모집_시기의_대학들_중에서_랜덤하게_N개를_추천_목록으로_구성한다() { + // given + generalUniversityRecommendService.init(); + List universities = generalUniversityRecommendService.getRecommendUniversities(); + + // when & then + assertAll( + () -> assertThat(universities) + .extracting("term") + .allMatch(term::equals), + () -> assertThat(universities).hasSize(RECOMMEND_UNIVERSITY_NUM) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java new file mode 100644 index 000000000..ec9b704a6 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java @@ -0,0 +1,176 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.LikeResultResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.custom.exception.ErrorCode.ALREADY_LIKED_UNIVERSITY; +import static com.example.solidconnection.custom.exception.ErrorCode.NOT_LIKED_UNIVERSITY; +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_CANCELED_MESSAGE; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_SUCCESS_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("대학교 좋아요 서비스 테스트") +class UniversityLikeServiceTest extends BaseIntegrationTest { + + @Autowired + private UniversityLikeService universityLikeService; + + @Autowired + private LikedUniversityRepository likedUniversityRepository; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Nested + class 대학_좋아요를_등록한다 { + + @Test + void 성공적으로_좋아요를_등록한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + LikeResultResponse response = universityLikeService.likeUniversity(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertAll( + () -> assertThat(response.result()).isEqualTo(LIKE_SUCCESS_MESSAGE), + () -> assertThat(likedUniversityRepository.findBySiteUserAndUniversityInfoForApply( + testUser, 괌대학_A_지원_정보 + )).isPresent() + ); + } + + @Test + void 이미_좋아요한_대학이면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + saveLikedUniversity(testUser, 괌대학_A_지원_정보); + + // when & then + assertThatCode(() -> universityLikeService.likeUniversity(testUser, 괌대학_A_지원_정보.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_LIKED_UNIVERSITY.getMessage()); + } + } + + @Nested + class 대학_좋아요를_취소한다 { + + @Test + void 성공적으로_좋아요를_취소한다() { + // given + SiteUser testUser = createSiteUser(); + saveLikedUniversity(testUser, 괌대학_A_지원_정보); + + // when + LikeResultResponse response = universityLikeService.cancelLikeUniversity(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertAll( + () -> assertThat(response.result()).isEqualTo(LIKE_CANCELED_MESSAGE), + () -> assertThat(likedUniversityRepository.findBySiteUserAndUniversityInfoForApply( + testUser, 괌대학_A_지원_정보 + )).isEmpty() + ); + } + + @Test + void 좋아요하지_않은_대학이면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + + // when & then + assertThatCode(() -> universityLikeService.cancelLikeUniversity(testUser, 괌대학_A_지원_정보.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_LIKED_UNIVERSITY.getMessage()); + } + } + + @Test + void 존재하지_않는_대학_좋아요_시도하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + Long invalidUniversityId = 9999L; + + // when & then + assertThatCode(() -> universityLikeService.likeUniversity(testUser, invalidUniversityId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); + } + + @Test + void 좋아요한_대학인지_확인한다() { + // given + SiteUser testUser = createSiteUser(); + saveLikedUniversity(testUser, 괌대학_A_지원_정보); + + // when + IsLikeResponse response = universityLikeService.getIsLiked(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.isLike()).isTrue(); + } + + @Test + void 좋아요하지_않은_대학인지_확인한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + IsLikeResponse response = universityLikeService.getIsLiked(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.isLike()).isFalse(); + } + + @Test + void 존재하지_않는_대학의_좋아요_여부를_조회하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + Long invalidUniversityId = 9999L; + + // when & then + assertThatCode(() -> universityLikeService.getIsLiked(testUser, invalidUniversityId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private void saveLikedUniversity(SiteUser siteUser, UniversityInfoForApply universityInfoForApply) { + LikedUniversity likedUniversity = LikedUniversity.builder() + .siteUser(siteUser) + .universityInfoForApply(universityInfoForApply) + .build(); + likedUniversityRepository.save(likedUniversity); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java new file mode 100644 index 000000000..1cd0d755f --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java @@ -0,0 +1,206 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.dto.UniversityDetailResponse; +import com.example.solidconnection.university.dto.LanguageRequirementResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponses; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.custom.UniversityFilterRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; + +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@DisplayName("대학교 조회 서비스 테스트") +class UniversityQueryServiceTest extends BaseIntegrationTest { + + @Autowired + private UniversityQueryService universityQueryService; + + @SpyBean + private UniversityFilterRepository universityFilterRepository; + + @SpyBean + private UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Test + void 대학_상세정보를_정상_조회한다() { + // given + Long universityId = 괌대학_A_지원_정보.getId(); + + // when + UniversityDetailResponse response = universityQueryService.getUniversityDetail(universityId); + + // then + Assertions.assertAll( + () -> assertThat(response.id()).isEqualTo(괌대학_A_지원_정보.getId()), + () -> assertThat(response.term()).isEqualTo(괌대학_A_지원_정보.getTerm()), + () -> assertThat(response.koreanName()).isEqualTo(괌대학_A_지원_정보.getKoreanName()), + () -> assertThat(response.englishName()).isEqualTo(영미권_미국_괌대학.getEnglishName()), + () -> assertThat(response.formatName()).isEqualTo(영미권_미국_괌대학.getFormatName()), + () -> assertThat(response.region()).isEqualTo(영미권.getKoreanName()), + () -> assertThat(response.country()).isEqualTo(미국.getKoreanName()), + () -> assertThat(response.homepageUrl()).isEqualTo(영미권_미국_괌대학.getHomepageUrl()), + () -> assertThat(response.logoImageUrl()).isEqualTo(영미권_미국_괌대학.getLogoImageUrl()), + () -> assertThat(response.backgroundImageUrl()).isEqualTo(영미권_미국_괌대학.getBackgroundImageUrl()), + () -> assertThat(response.detailsForLocal()).isEqualTo(영미권_미국_괌대학.getDetailsForLocal()), + () -> assertThat(response.studentCapacity()).isEqualTo(괌대학_A_지원_정보.getStudentCapacity()), + () -> assertThat(response.tuitionFeeType()).isEqualTo(괌대학_A_지원_정보.getTuitionFeeType().getKoreanName()), + () -> assertThat(response.semesterAvailableForDispatch()).isEqualTo(괌대학_A_지원_정보.getSemesterAvailableForDispatch().getKoreanName()), + () -> assertThat(response.languageRequirements()).containsOnlyOnceElementsOf( + 괌대학_A_지원_정보.getLanguageRequirements().stream() + .map(LanguageRequirementResponse::from) + .toList()), + () -> assertThat(response.detailsForLanguage()).isEqualTo(괌대학_A_지원_정보.getDetailsForLanguage()), + () -> assertThat(response.gpaRequirement()).isEqualTo(괌대학_A_지원_정보.getGpaRequirement()), + () -> assertThat(response.gpaRequirementCriteria()).isEqualTo(괌대학_A_지원_정보.getGpaRequirementCriteria()), + () -> assertThat(response.semesterRequirement()).isEqualTo(괌대학_A_지원_정보.getSemesterRequirement()), + () -> assertThat(response.detailsForApply()).isEqualTo(괌대학_A_지원_정보.getDetailsForApply()), + () -> assertThat(response.detailsForMajor()).isEqualTo(괌대학_A_지원_정보.getDetailsForMajor()), + () -> assertThat(response.detailsForAccommodation()).isEqualTo(괌대학_A_지원_정보.getDetailsForAccommodation()), + () -> assertThat(response.detailsForEnglishCourse()).isEqualTo(괌대학_A_지원_정보.getDetailsForEnglishCourse()), + () -> assertThat(response.details()).isEqualTo(괌대학_A_지원_정보.getDetails()), + () -> assertThat(response.accommodationUrl()).isEqualTo(괌대학_A_지원_정보.getUniversity().getAccommodationUrl()), + () -> assertThat(response.englishCourseUrl()).isEqualTo(괌대학_A_지원_정보.getUniversity().getEnglishCourseUrl()) + ); + } + + @Test + void 대학_상세정보_조회시_캐시가_적용된다() { + // given + Long universityId = 괌대학_A_지원_정보.getId(); + + // when + UniversityDetailResponse firstResponse = universityQueryService.getUniversityDetail(universityId); + UniversityDetailResponse secondResponse = universityQueryService.getUniversityDetail(universityId); + + // then + assertThat(firstResponse).isEqualTo(secondResponse); + then(universityInfoForApplyRepository).should(times(1)).getUniversityInfoForApplyById(universityId); + } + + @Test + void 존재하지_않는_대학_상세정보를_조회하면_예외_응답을_반환한다() { + // given + Long invalidUniversityInfoForApplyId = 9999L; + + // when & then + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> universityQueryService.getUniversityDetail(invalidUniversityInfoForApplyId)) + .havingRootCause() + .isInstanceOf(CustomException.class) + .withMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); + } + + @Test + void 전체_대학을_조회한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + null, List.of(), null, null); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(린츠_카톨릭대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Test + void 대학_조회시_캐시가_적용된다() { + // given + String regionCode = 영미권.getCode(); + List keywords = List.of("괌"); + LanguageTestType testType = LanguageTestType.TOEFL_IBT; + String testScore = "70"; + String term = "2024-1"; + + // when + UniversityInfoForApplyPreviewResponses firstResponse = + universityQueryService.searchUniversity(regionCode, keywords, testType, testScore); + UniversityInfoForApplyPreviewResponses secondResponse = + universityQueryService.searchUniversity(regionCode, keywords, testType, testScore); + + // then + assertThat(firstResponse).isEqualTo(secondResponse); + then(universityFilterRepository).should(times(1)) + .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( + regionCode, keywords, testType, testScore, term); + } + + @Test + void 지역으로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + 영미권.getCode(), List.of(), null, null); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보) + ); + } + + @Test + void 키워드로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + null, List.of("라", "일본"), null, null); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Test + void 어학시험_조건으로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + null, List.of(), LanguageTestType.TOEFL_IBT, "70"); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보) + ); + } + + @Test + void 모든_조건으로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + "EUROPE", List.of(), LanguageTestType.TOEFL_IBT, "70"); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()).containsExactly(UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보)); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java new file mode 100644 index 000000000..102eb6dd6 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java @@ -0,0 +1,154 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +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 java.util.List; + +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("대학교 추천 서비스 테스트") +class UniversityRecommendServiceTest extends BaseIntegrationTest { + + @Autowired + private UniversityRecommendService universityRecommendService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private InterestedCountyRepository interestedCountyRepository; + + @Autowired + private GeneralUniversityRecommendService generalUniversityRecommendService; + + @BeforeEach + void setUp() { + generalUniversityRecommendService.init(); + } + + @Test + void 관심_지역_설정한_사용자의_맞춤_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + interestedRegionRepository.save(new InterestedRegion(testUser, 영미권)); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsAll(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보) + )); + } + + @Test + void 관심_국가_설정한_사용자의_맞춤_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + interestedCountyRepository.save(new InterestedCountry(testUser, 덴마크)); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsAll(List.of( + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )); + } + + @Test + void 관심_지역과_국가_모두_설정한_사용자의_맞춤_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + interestedRegionRepository.save(new InterestedRegion(testUser, 영미권)); + interestedCountyRepository.save(new InterestedCountry(testUser, 덴마크)); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + ); + } + + @Test + void 관심사_미설정_사용자는_일반_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsExactlyInAnyOrderElementsOf( + generalUniversityRecommendService.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList() + ); + } + + @Test + void 일반_추천_대학을_조회한다() { + // when + UniversityRecommendsResponse response = universityRecommendService.getGeneralRecommends(); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsExactlyInAnyOrderElementsOf( + generalUniversityRecommendService.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList() + ); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } +} diff --git a/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java new file mode 100644 index 000000000..95bdd5a52 --- /dev/null +++ b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java @@ -0,0 +1,185 @@ +package com.example.solidconnection.util; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Date; + +import static com.example.solidconnection.util.JwtUtils.parseSubject; +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; +import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("JwtUtils 테스트") +class JwtUtilsTest { + + private final String jwtSecretKey = "jwt-secret-key"; + + @Nested + class 요청으로부터_토큰을_추출한다 { + + @Test + void 토큰이_있으면_토큰을_반환한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + String token = "token"; + request.addHeader("Authorization", "Bearer " + token); + + // when + String extractedToken = parseTokenFromRequest(request); + + // then + assertThat(extractedToken).isEqualTo(token); + } + + @Test + void 토큰이_없으면_null_을_반환한다() { + // given + MockHttpServletRequest noHeader = new MockHttpServletRequest(); + MockHttpServletRequest wrongPrefix = new MockHttpServletRequest(); + wrongPrefix.addHeader("Authorization", "Wrong token"); + MockHttpServletRequest emptyToken = new MockHttpServletRequest(); + wrongPrefix.addHeader("Authorization", "Bearer "); + + // when & then + assertAll( + () -> assertThat(parseTokenFromRequest(noHeader)).isNull(), + () -> assertThat(parseTokenFromRequest(wrongPrefix)).isNull(), + () -> assertThat(parseTokenFromRequest(emptyToken)).isNull() + ); + } + } + + @Nested + class 유효한_토큰으로부터_subject_를_추출한다 { + + @Test + void 유효한_토큰의_subject_를_추출한다() { + // given + String subject = "subject000"; + String token = createValidToken(subject); + + // when + String extractedSubject = parseSubject(token, jwtSecretKey); + + // then + assertThat(extractedSubject).isEqualTo(subject); + } + + @Test + void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { + // given + String subject = "subject123"; + String token = createExpiredToken(subject); + + // when + assertThatCode(() -> parseSubject(token, jwtSecretKey)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + @Nested + class 만료된_토큰으로부터_subject_를_추출한다 { + + @Test + void 만료된_토큰의_subject_를_예외를_발생시키지_않고_추출한다() { + // given + String subject = "subject999"; + String token = createExpiredToken(subject); + + // when + String extractedSubject = parseSubjectIgnoringExpiration(token, jwtSecretKey); + + // then + assertThat(extractedSubject).isEqualTo(subject); + } + + @Test + void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { + // given + String token = createExpiredUnsignedToken("hackers secret key"); + + // when & then + assertThatCode(() -> parseSubjectIgnoringExpiration(token, jwtSecretKey)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + + @Nested + class 토큰이_만료되었는지_확인한다 { + + @Test + void 서명된_토큰의_만료_여부를_반환한다() { + // given + String subject = "subject123"; + String validToken = createValidToken(subject); + String expiredToken = createExpiredToken(subject); + + // when + boolean isExpired1 = JwtUtils.isExpired(validToken, jwtSecretKey); + boolean isExpired2 = JwtUtils.isExpired(expiredToken, jwtSecretKey); + + // then + assertAll( + () -> assertThat(isExpired1).isFalse(), + () -> assertThat(isExpired2).isTrue() + ); + } + + @Test + void 서명되지_않은_토큰의_만료_여부를_반환한다() { + // given + String subject = "subject123"; + String validToken = createValidToken(subject); + String expiredToken = createExpiredToken(subject); + + // when + boolean isExpired1 = JwtUtils.isExpired(validToken, "wrong-secret-key"); + boolean isExpired2 = JwtUtils.isExpired(expiredToken, "wrong-secret-key"); + + // then + assertAll( + () -> assertThat(isExpired1).isTrue(), + () -> assertThat(isExpired2).isTrue() + ); + } + } + + private String createValidToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } + + private String createExpiredToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } + + private String createExpiredUnsignedToken(String jwtSecretKey) { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } +}