diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 000000000..2a38744c1 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,50 @@ +name: Build test with Gradle + +on: + push: + branches: + - develop + paths: + - 'backend/**' + pull_request: + branches: + - develop + paths: + - 'backend/**' + +jobs: + build: + permissions: + contents: read + issues: read + checks: write + pull-requests: write + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew test + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: | + ./backend/build/test-results/**/*.xml diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml new file mode 100644 index 000000000..c6c91e2d9 --- /dev/null +++ b/.github/workflows/backend-dev-cd.yml @@ -0,0 +1,98 @@ +name: "[DEVELOP] CD using Github self-hosted runner" + +on: + workflow_dispatch: + push: + branches: + - develop + paths: + - 'backend/**' + +env: + ARTIFACT_NAME: review-me-dev + ARTIFACT_DIRECTORY: ./backend/build/libs + APPLICATION_DIRECTORY: ~/review-me-app + +jobs: + build: + name: Build Jar file and upload artifact + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew clean bootJar + + - name: Rename artifact file + run: | + mv ${{ env.ARTIFACT_DIRECTORY }}/*.jar ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + - name: Upload created artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + deploy: + name: Deploy via self-hosted runner + needs: build + runs-on: [self-hosted, dev] + + steps: + - name: Checkout to secret repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPOSITORY_URL }} + token: ${{ secrets.PRIVATE_REPOSITORY_TOKEN }} + + - name: Download uploaded artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + + - name: Copy application related files to other directory + run: | + sudo mv * ${{ env.APPLICATION_DIRECTORY }} + + - name: Find ${{ env.ARTIFACT_NAME }} process + run: | + echo "Checking processes..." + PID=$(pgrep -f ${{ env.ARTIFACT_NAME }}.jar -d " " || true) + if [ -n "$PID" ]; then + echo "Found processes: $PID" + echo "server_running=true" >> "$GITHUB_ENV" + echo "PID=$PID" >> "$GITHUB_ENV" + else + echo "Process not found!" + echo "server_running=false" >> "$GITHUB_ENV" + fi + + - name: Stop server if available (gracefully) + if: env.server_running == 'true' + run: | + echo "Gracefully shutting down process ${{ env.PID }}" + for PID in ${{ env.PID }}; do + sudo kill -15 $PID | true + tail --pid=$PID -f /dev/null | true + done + + - name: Start server + run: | + cd ${{ env.APPLICATION_DIRECTORY }} + sudo nohup java -jar ${{ env.ARTIFACT_NAME }}.jar --server.port=8080 --spring.config.location=application-dev.yml & diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml new file mode 100644 index 000000000..4b3a6bcf6 --- /dev/null +++ b/.github/workflows/backend-prod-cd.yml @@ -0,0 +1,98 @@ +name: "[RELEASE] CD using Github self-hosted runner" + +on: + workflow_dispatch: + push: + branches: + - release + paths: + - 'backend/**' + +env: + ARTIFACT_NAME: review-me-prod + ARTIFACT_DIRECTORY: ./backend/build/libs + APPLICATION_DIRECTORY: ~/review-me-app + +jobs: + build: + name: Build Jar file and upload artifact + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew clean bootJar + + - name: Rename artifact file + run: | + mv ${{ env.ARTIFACT_DIRECTORY }}/*.jar ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + - name: Upload created artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + deploy: + name: Deploy via self-hosted runner + needs: build + runs-on: [self-hosted, prod] + + steps: + - name: Checkout to secret repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPOSITORY_URL }} + token: ${{ secrets.PRIVATE_REPOSITORY_TOKEN }} + + - name: Download uploaded artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + + - name: Copy application related files to other directory + run: | + sudo mv * ${{ env.APPLICATION_DIRECTORY }} + + - name: Find ${{ env.ARTIFACT_NAME }} process + run: | + echo "Checking processes..." + PID=$(pgrep -f ${{ env.ARTIFACT_NAME }}.jar -d " " || true) + if [ -n "$PID" ]; then + echo "Found processes: $PID" + echo "server_running=true" >> "$GITHUB_ENV" + echo "PID=$PID" >> "$GITHUB_ENV" + else + echo "Process not found!" + echo "server_running=false" >> "$GITHUB_ENV" + fi + + - name: Stop server if available (gracefully) + if: env.server_running == 'true' + run: | + echo "Gracefully shutting down process ${{ env.PID }}" + for PID in ${{ env.PID }}; do + sudo kill -15 $PID | true + tail --pid=$PID -f /dev/null | true + done + + - name: Start server + run: | + cd ${{ env.APPLICATION_DIRECTORY }} + sudo nohup java -jar ${{ env.ARTIFACT_NAME }}.jar --server.port=8080 --spring.config.location=application-prod.yml & diff --git a/.github/workflows/discord-pull-request-comment.yml b/.github/workflows/discord-pull-request-comment.yml new file mode 100644 index 000000000..e1a3fc36b --- /dev/null +++ b/.github/workflows/discord-pull-request-comment.yml @@ -0,0 +1,106 @@ +name: Mention Discord on Pull Request Review + +on: + pull_request_review: + types: [ submitted ] + +env: + "31026350": "206298119661420544" + "64690761": "243991296060948491" + "69838872": "1165830186990850110" + "111052302": "859318944195149855" + "145949635": "1164111111193366580" + "76177848": "710749110570975243" + "110809927": "971312723260493834" + "80167893": "1162754699099906169" + "backend": "1263405654534525051" + "frontend": "1263406763382931467" + +jobs: + notify-on-pr: + runs-on: ubuntu-latest + steps: + - name: Find prefix for PR title + run: | + echo "Finding prefix for PR title" + PR_TITLE='${{ github.event.pull_request.title }}' + PR_PREFIX=$(echo $PR_TITLE | cut -d ' ' -f1) + if [ "$PR_PREFIX" = '[BE]' ]; then + echo Backend PR Found! + echo "PR_PREFIX=BE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[FE]' ]; then + echo Frontend PR Found! + echo "PR_PREFIX=FE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[All]' ]; then + echo All PR Found! + echo "PR_PREFIX=All" >> $GITHUB_ENV + fi + echo PR Prefix : $PR_PREFIX + echo PR Prefix on env : ${{ env.PR_PREFIX }} + + + - name: Notify on PR Review + if: github.event.review.state == 'approved' || github.event.review.state == 'changes_requested' + run: | + echo "Notify on Discord" + + PR_URL='${{ github.event.pull_request.html_url }}' + PR_TITLE='${{ github.event.pull_request.title }}' + PR_AUTHOR='${{ github.event.pull_request.user.login }}' + REVIEWER='${{ github.event.review.user.login }}' + + REVIEWER_DISCORD_ID='${{ env[github.event.review.user.id] }}' + AUTHOR_DISCORD_ID='${{ env[github.event.pull_request.user.id] }}' + + if [ "${{ env.PR_PREFIX }}" = 'BE' ]; then + WEBHOOK_URL=${{ secrets.DISCORD_BE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'FE' ]; then + WEBHOOK_URL=${{ secrets.DISCORD_FE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'All' ]; then + WEBHOOK_URL=${{ secrets.DISCORD_ALL_PR_WEBHOOK_URL }} + fi + + if [ "${{ github.event.review.state }}" = 'approved' ]; then + COMMENT="PR Approved 되었습니다 🚀" + COLOR=65305 + elif [ "${{ github.event.review.state }}" = 'changes_requested' ]; then + COMMENT="PR에 수정 요구사항이 있습니다 👀" + COLOR=16736293 + else + echo "Invalid review state" + exit 0 + fi + + JSON_FILE=$(mktemp) + cat > $JSON_FILE < $COMMENT", + "embeds": [ + { + "author": { + "name": "$PR_AUTHOR", + "icon_url": "https://github.com/$PR_AUTHOR.png" + }, + "title": "$PR_TITLE", + "url": "$PR_URL", + "color": $COLOR, + "footer": { + "text": "2024-review-me" + }, + "fields": [ + { + "name": "리뷰어", + "value": "<@$REVIEWER_DISCORD_ID>", + "inline": true + } + ], + "timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + } + ] + } + EOF + cat $JSON_FILE + curl -X POST -H 'Content-type: application/json' \ + --data @$JSON_FILE \ + $WEBHOOK_URL + rm $JSON_FILE diff --git a/.github/workflows/discord-pull-request.yml b/.github/workflows/discord-pull-request.yml new file mode 100644 index 000000000..52b446ebb --- /dev/null +++ b/.github/workflows/discord-pull-request.yml @@ -0,0 +1,85 @@ +name: Mention Discord on Pull Request + +on: + pull_request: + types: [ opened, reopened ] # PR이 열렸을 때에만 작동합니다. + +env: + "31026350": "206298119661420544" + "64690761": "243991296060948491" + "69838872": "1165830186990850110" + "111052302": "859318944195149855" + "145949635": "1164111111193366580" + "76177848": "710749110570975243" + "110809927": "971312723260493834" + "80167893": "1162754699099906169" + "backend": "1263405654534525051" + "frontend": "1263406763382931467" + +jobs: + notify-on-pr: + runs-on: ubuntu-latest + steps: + - name: Find prefix for PR title + run: | + echo "Finding prefix for PR title" + PR_TITLE='${{ github.event.pull_request.title }}' + PR_PREFIX=$(echo $PR_TITLE | cut -d ' ' -f1) + if [ "$PR_PREFIX" = '[BE]' ]; then + echo Backend PR Found! + echo "PR_PREFIX=BE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[FE]' ]; then + echo Frontend PR Found! + echo "PR_PREFIX=FE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[All]' ]; then + echo All PR Found! + echo "PR_PREFIX=All" >> $GITHUB_ENV + fi + echo PR Prefix : $PR_PREFIX + + - name: Notify on PR + if: env.PR_PREFIX == 'BE' || env.PR_PREFIX == 'FE' || env.PR_PREFIX == 'All' + run: | + echo "Notify on Discord" + + PR_URL='${{ github.event.pull_request.html_url }}' + PR_TITLE='${{ github.event.pull_request.title }}' + PR_AUTHOR='${{ github.event.sender.login }}' + DISCORD_ID='${{ env[github.event.sender.id] }}' + if [ "${{ env.PR_PREFIX }}" = 'BE' ]; then + NOTIFY_CONTENT="<@&${{ env.backend }}>" + WEBHOOK_URL=${{ secrets.DISCORD_BE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'FE' ]; then + NOTIFY_CONTENT="<@&${{ env.frontend }}>" + WEBHOOK_URL=${{ secrets.DISCORD_FE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'All' ]; then + NOTIFY_CONTENT="<@&${{ env.backend }}> <@&${{ env.frontend }}>" + WEBHOOK_URL=${{ secrets.DISCORD_ALL_PR_WEBHOOK_URL }} + fi + + JSON_FILE=$(mktemp) + cat > $JSON_FILE < ./frontend/.env + + - name: Set environment file permissions + run: chmod 644 ./frontend/.env + + - name: Install dependencies + run: yarn install --frozen-lockfile + working-directory: frontend + + - name: Run tests + run: yarn test + working-directory: frontend + + - name: Build + run: yarn build + env: + API_BASE_URL: ${{ secrets.API_BASE_URL }} + working-directory: frontend diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..f3f31fc6a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "backend/src/main/resources/secret"] + path = backend/src/main/resources/secret + url = https://github.com/woowacourse-teams/2024-review-me-secret.git diff --git a/README.md b/README.md index 40c5f4211..c7e1b40ba 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ # 리뷰미 + +> 🤔 우리 팀원은 나를 어떻게 생각할까? +> 🫂 나와 팀이 함께 성장하려면 어떻게 해야 할까? +> 🤨 팀원에게 하고 싶은 말이 있는데, 대면으로 하기가 민망하네.. +> 🥹 기능 구현 하기에도 바빠서 문화를 챙길 시간도 없고, 팀원들한테 이런거 하자고 하기도 부담스러워... + +저희도 스스로가 팀에서 어떤 존재인지 고민될 때가 있습니다. + +동료의 피드백을 통해 저희는 자신의 강점과 팀에 어떻게 기여할 수 있는지를 알게 되었습니다. +지칠 때 받은 동료의 리뷰가 큰 힘이 되었어요. 팀원 모두가 서로를 응원하니 자연스럽게 팀워크도 향상됐습니다. + +리뷰미는 동료로부터 기술뿐만 아니라 소프트 스킬, 나의 특징 등을 다방면으로 리뷰 받을 수 있는 서비스입니다. +리뷰미를 통해 협업하는 내 모습을 알아갈 수 있고, 나아가 함께 성장하는 방식을 고민할 수 있습니다. +어쩌면 내가 몰랐던 내 모습을 발견할 수도 있겠죠? + +여러분들도 리뷰를 통한 좋은 경험을 해보고 싶으시다면, +리뷰를 통해 누군가에게 응원을 전달하고 싶으시다면, +리뷰미와 함께하세요! diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,37 @@ +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/ diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 000000000..cfab09856 --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,85 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +group = 'review-me' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // RestDocs + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.0.1' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.1' + testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0' + testImplementation 'io.rest-assured:rest-assured:5.4.0' +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + useJUnitPlatform() + outputs.dir snippetsDir +} + +asciidoctor { + configurations 'asciidoctorExt' + + sources { + include("**/index.adoc") + } + baseDirFollowsSourceFile() + + inputs.dir snippetsDir + dependsOn test +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + +tasks.register('copyRestDocs', Copy) { + dependsOn asciidoctor + from file('build/docs/asciidoc') + into file('src/main/resources/static/docs') +} + +build { + dependsOn copyRestDocs +} diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e6441136f Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a4413138c --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 000000000..b740cf133 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 000000000..0f5036dcc --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/backend/src/docs/asciidoc/create-review.adoc b/backend/src/docs/asciidoc/create-review.adoc new file mode 100644 index 000000000..7b3464613 --- /dev/null +++ b/backend/src/docs/asciidoc/create-review.adoc @@ -0,0 +1,7 @@ +==== 리뷰 생성 + +operation::create-review[snippets="curl-request,request-fields,http-response"] + +==== 그룹 코드가 올바르지 않은 경우 + +operation::create-review-invalid-review-request-code[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/get-review-form.adoc b/backend/src/docs/asciidoc/get-review-form.adoc new file mode 100644 index 000000000..3d8a9ddee --- /dev/null +++ b/backend/src/docs/asciidoc/get-review-form.adoc @@ -0,0 +1,7 @@ +==== 리뷰 작성을 위한 폼 가져오기 + +operation::get-review-form[snippets="curl-request,http-response,response-fields"] + +==== 그룹 코드가 올바르지 않은 경우 + +operation::get-review-form-not-found[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..d94d361b6 --- /dev/null +++ b/backend/src/docs/asciidoc/index.adoc @@ -0,0 +1,31 @@ += 리뷰미 Rest API Docs +:doctype: book +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectlinks: +:sectnums: 2 + +== 리뷰 그룹 + +include::reviewgroup.adoc[] + +== 리뷰 작성 + +=== 리뷰 작성을 위한 질문지 조회 + +include::get-review-form.adoc[] + +=== 리뷰 작성 + +include::create-review.adoc[] + +== 리뷰 조회 + +=== 리뷰 단건 조회 + +include::review-list.adoc[] + +=== 리뷰 목록 조회 + +include::review-detail.adoc[] diff --git a/backend/src/docs/asciidoc/review-detail.adoc b/backend/src/docs/asciidoc/review-detail.adoc new file mode 100644 index 000000000..26317af36 --- /dev/null +++ b/backend/src/docs/asciidoc/review-detail.adoc @@ -0,0 +1,7 @@ +==== 리뷰 단건 조회 + +operation::review-detail[snippets="curl-request,request-headers,path-parameters,http-response,response-fields"] + +==== 접근 코드가 올바르지 않은 경우 + +operation::review-detail-invalid-group-access-code[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/review-list.adoc b/backend/src/docs/asciidoc/review-list.adoc new file mode 100644 index 000000000..5c2694073 --- /dev/null +++ b/backend/src/docs/asciidoc/review-list.adoc @@ -0,0 +1,7 @@ +==== 자신이 받은 리뷰 목록 조회 + +operation::received-reviews[snippets="curl-request,request-headers,http-response,response-fields"] + +==== 접근 코드가 올바르지 않은 경우 + +operation::received-reviews-invalid-group-access-code[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/reviewgroup.adoc b/backend/src/docs/asciidoc/reviewgroup.adoc new file mode 100644 index 000000000..666d1e862 --- /dev/null +++ b/backend/src/docs/asciidoc/reviewgroup.adoc @@ -0,0 +1,11 @@ +==== 리뷰 그룹 생성 + +operation::review-group-create[snippets="curl-request,request-fields,http-response,response-fields"] + +==== 리뷰 그룹 간단 정보 조회 + +operation::review-group-summary[snippets="curl-request,request-headers,http-response,response-fields"] + +==== 리뷰 요청 코드, 확인 코드 일치 여부 + +operation::review-group-check-access[snippets="curl-request,request-fields,http-response,response-fields"] diff --git a/backend/src/main/java/reviewme/DatabaseInitializer.java b/backend/src/main/java/reviewme/DatabaseInitializer.java new file mode 100644 index 000000000..b8a6302bd --- /dev/null +++ b/backend/src/main/java/reviewme/DatabaseInitializer.java @@ -0,0 +1,146 @@ +package reviewme; + +import jakarta.annotation.PostConstruct; +import jakarta.transaction.Transactional; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@Component +@RequiredArgsConstructor +public class DatabaseInitializer { + + private static final String CATEGORY_HEADER = "이제, 선택한 순간들을 바탕으로 {revieweeName}에 대한 리뷰를 작성해볼게요."; + private static final String CATEGORY_TEXT_QUESTION = "위에서 선택한 사항과 관련된 경험을 구체적으로 적어 주세요."; + private static final int KEYWORD_CHECKBOX_MIN_COUNT = 1; + private static final int KEYWORD_CHECKBOX_MAX_COUNT = 2; + + private final QuestionRepository questionRepository; + private final OptionItemRepository optionItemRepository; + private final OptionGroupRepository optionGroupRepository; + private final SectionRepository sectionRepository; + private final TemplateRepository templateRepository; + + @PostConstruct + @Transactional + void setup() { + // 템플릿이 이미 존재하면 종료 + if (!templateRepository.findAll().isEmpty()) { + return; + } + + // 카테고리 선택 섹션 + long categoryQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "프로젝트 기간 동안, {revieweeName}의 강점이 드러났던 순간을 선택해주세요.", null, 1)).getId(); + long categorySectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(categoryQuestionId), null, "강점 발견", "{revieweeName}와 함께 한 기억을 떠올려볼게요.", 1)).getId(); + long categoryOptionGroupId = optionGroupRepository.save(new OptionGroup(categoryQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + long communicationOptionId = optionItemRepository.save(new OptionItem("🗣️커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)", categoryOptionGroupId, 1, OptionType.CATEGORY)).getId(); + long problemSolvingOptionId = optionItemRepository.save(new OptionItem("💡문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)",categoryOptionGroupId,2, OptionType.CATEGORY )).getId(); + long timeManagingOptionId = optionItemRepository.save(new OptionItem("⏰시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)",categoryOptionGroupId,3, OptionType.CATEGORY )).getId(); + long technicalOptionId = optionItemRepository.save(new OptionItem("💻기술적 역량, 전문 지식 (ex: 요구 사항을 이해하고 이를 구현하는 능력)",categoryOptionGroupId,4, OptionType.CATEGORY )).getId(); + long growthOptionId = optionItemRepository.save(new OptionItem("🌱성장 마인드셋 (ex: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)",categoryOptionGroupId,5, OptionType.CATEGORY )).getId(); + + // 커뮤니케이션 능력 섹션 + long checkBoxCommunicationQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textCommunicationQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. {revieweeName} 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요.", 2)).getId(); + long communicationSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxCommunicationQuestionId, textCommunicationQuestionId), communicationOptionId, "커뮤니케이션 능력", CATEGORY_HEADER, 2)).getId(); + long communicationOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxCommunicationQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.",communicationOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.",communicationOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀의 분위기를 주도해요.",communicationOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("주장을 이야기할 때에는 합당한 근거가 뒤따라요.",communicationOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요.",communicationOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀 내 주어진 요구사항에 우선순위를 잘 매겨요.",communicationOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("서로 다른 분야간의 소통도 중요하게 생각해요.",communicationOptionGroupId,7, OptionType.KEYWORD )); + + // 문제해결 능력 섹션 + long checkBoxProblemSolvingQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "문제해결 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textProblemSolvingQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. 어떤 문제 상황이 발생했고, {revieweeName}이/가 어떻게 해결했는지 그 과정을 떠올려 보세요.", 2)).getId(); + long problemSolvingSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxProblemSolvingQuestionId, textProblemSolvingQuestionId), problemSolvingOptionId, "문제해결 능력", CATEGORY_HEADER, 3)).getId(); + long problemSolvingOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxProblemSolvingQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("큰 문제를 작은 단위로 쪼개서 단계별로 해결해나가요.",problemSolvingOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("낯선 문제를 만나도 당황하지 않고 차분하게 풀어나가요.",problemSolvingOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제 해결을 위해 GPT등의 자원을 적극적으로 활용해요.",problemSolvingOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제를 해결한 뒤에도 재발 방지를 위한 노력을 기울여요. (예: 문서화, 테스트 케이스 추가 등)",problemSolvingOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제의 원인을 적극적으로 탐구하고 해결해요. (예: 디버깅 툴의 적극적 활용 등)",problemSolvingOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("어려운 문제를 만나도 피하지 않고 도전해요.",problemSolvingOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제를 해결하기 위해 타인과 의사소통을 할 수 있어요. (예: 팀원과 이슈 공유, 문제 상황 설명 등)",problemSolvingOptionGroupId,7, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제 원인과 해결책에 대한 가설을 세우고 직접 실험해봐요.",problemSolvingOptionGroupId,8, OptionType.KEYWORD )); + + // 시간 관리 능력 섹션 + long checkBoxTimeManagingQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "시간 관리 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textTimeManagingQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. {revieweeName} 덕분에 팀이 효율적으로 시간관리를 할 수 있었는지 떠올려 보세요.", 2)).getId(); + long timeManagingSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxTimeManagingQuestionId, textTimeManagingQuestionId), timeManagingOptionId, "시간관리 능력", CATEGORY_HEADER, 4)).getId(); + long timeManagingOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxTimeManagingQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("프로젝트의 일정과 주요 마일스톤을 설정하여 체계적으로 일정을 관리해요.",timeManagingOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("일정에 따라 마감 기한을 잘 지켜요.",timeManagingOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("업무의 중요도와 긴급성을 고려하여 우선 순위를 정하고, 그에 따라 작업을 분배해요.",timeManagingOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("예기치 않은 일정 변경에도 유연하게 대처해요.",timeManagingOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("회의 시간과 같은 약속된 시간을 잘 지켜요.",timeManagingOptionGroupId,5, OptionType.KEYWORD )); + + // 기술 역량 섹션 + long checkBoxTechnicalQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "기술 역량, 전문 지식에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textTechnicalQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. {revieweeName} 덕분에 기술적 역량, 전문 지식적으로 도움을 받은 경험을 떠올려 보세요.", 2)).getId(); + long technicalSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxTechnicalQuestionId, textTechnicalQuestionId), technicalOptionId, "기술 역량", CATEGORY_HEADER, 5)).getId(); + long technicalOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxTechnicalQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("관련 언어 / 라이브러리 / 프레임워크 지식이 풍부해요.",technicalOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("인프라 지식이 풍부해요.",technicalOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("CS 지식이 풍부해요.",technicalOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("코드 리뷰에서 중요한 개선점을 제안했어요.",technicalOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("리팩토링을 통해 전체 코드의 품질을 향상시켰어요.",technicalOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("복잡한 버그를 신속하게 찾고 해결했어요.",technicalOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("꼼꼼하게 테스트를 작성했어요.",technicalOptionGroupId,7, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("처음 보는 기술을 빠르게 습득하여 팀 프로젝트에 적용했어요.",technicalOptionGroupId,8, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("명확하고 자세한 기술 문서를 작성하여 팀의 이해를 도왔어요.",technicalOptionGroupId,9, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("컨벤션을 잘 지키면서 클린 코드를 작성하려고 노력했어요.",technicalOptionGroupId,10, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("성능 최적화에 기여했어요.",technicalOptionGroupId,11, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("지속적인 학습과 공유를 통해 팀의 기술 수준을 높였어요.",technicalOptionGroupId,12, OptionType.KEYWORD )); + + // 성장 마인드셋 섹션 + long checkBoxGrowthQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "성장 마인드셋에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textGrowthQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. 인상깊었던 {revieweeName}의 성장 마인드셋을 떠올려 보세요.", 2)).getId(); + long growthSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxGrowthQuestionId, textGrowthQuestionId), growthOptionId, "성장 마인드셋", CATEGORY_HEADER, 6)).getId(); + long growthOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxGrowthQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("어떤 상황에도 긍정적인 태도로 임해요.",growthOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("주변 사람들한테 질문하는 것을 부끄러워하지 않아요.",growthOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("어려움이 있어도 끝까지 해내요.",growthOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("함께 성장하기 위해, 배운 내용을 다른 사람과 공유해요.",growthOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("새로운 것을 두려워하지 않고 적극적으로 배워나가요.",growthOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("이론적 학습에서 그치지 않고 직접 적용하려 노력해요.",growthOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("다른 사람들과 비교하지 않고 본인만의 속도로 성장하는 법을 알고 있어요.",growthOptionGroupId,7, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("받은 피드백을 빠르게 수용해요.",growthOptionGroupId,8, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("회고를 통해 성장할 수 있는 방법을 스스로 탐색해요.",growthOptionGroupId,9, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("새로운 아이디어를 시도하고, 기존의 틀을 깨는 것을 두려워하지 않아요.",growthOptionGroupId,10, OptionType.KEYWORD )); + + // 성장 목표 설정 섹션 + long textGrowthGoalQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, "앞으로의 성장을 위해서 {revieweeName}이/가 어떤 목표를 설정하면 좋을까요?", "어떤 점을 보완하면 좋을지와 함께 '이렇게 해보면 어떨까?'하는 간단한 솔루션을 제안해봐요.", 1)).getId(); + long textGrowthGoalSectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(textGrowthGoalQuestionId), null, "보완할 점", "{revieweeName}의 성장을 도와주세요!", 7)).getId(); + + // 응원의 말 섹션 + long textCheerUpQuestionId = questionRepository.save(new Question(false, QuestionType.TEXT, "{revieweeName}에게 전하고 싶은 다른 리뷰가 있거나 응원의 말이 있다면 적어주세요.", null, 1)).getId(); + long cheerUpSectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(textCheerUpQuestionId), null, "추가 리뷰/응원", "리뷰를 더 하고 싶은 리뷰어를 위한 추가 리뷰!", 8)).getId(); + + templateRepository.save(new Template(List.of( + categorySectionId, + communicationSectionId, + problemSolvingSectionId, + timeManagingSectionId, + technicalSectionId, + growthSectionId, + textGrowthGoalSectionId, + cheerUpSectionId + ))); + } +} diff --git a/backend/src/main/java/reviewme/ReviewMeApplication.java b/backend/src/main/java/reviewme/ReviewMeApplication.java new file mode 100644 index 000000000..7645801f3 --- /dev/null +++ b/backend/src/main/java/reviewme/ReviewMeApplication.java @@ -0,0 +1,12 @@ +package reviewme; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ReviewMeApplication { + + public static void main(String[] args) { + SpringApplication.run(ReviewMeApplication.class, args); + } +} diff --git a/backend/src/main/java/reviewme/config/CorsConfig.java b/backend/src/main/java/reviewme/config/CorsConfig.java new file mode 100644 index 000000000..448e95eb7 --- /dev/null +++ b/backend/src/main/java/reviewme/config/CorsConfig.java @@ -0,0 +1,16 @@ +package reviewme.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedMethods("*") + .allowedOrigins("*"); + } +} diff --git a/backend/src/main/java/reviewme/config/SwaggerConfig.java b/backend/src/main/java/reviewme/config/SwaggerConfig.java new file mode 100644 index 000000000..0c6becb78 --- /dev/null +++ b/backend/src/main/java/reviewme/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package reviewme.config; + +import io.swagger.v3.oas.models.OpenAPI; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reviewme.config.properties.SwaggerProperties; + +@Configuration +@EnableConfigurationProperties(SwaggerProperties.class) +@RequiredArgsConstructor +public class SwaggerConfig { + + private final SwaggerProperties swaggerProperties; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(swaggerProperties.swaggerInfo()); + } +} diff --git a/backend/src/main/java/reviewme/config/WebConfig.java b/backend/src/main/java/reviewme/config/WebConfig.java new file mode 100644 index 000000000..423c8f0e5 --- /dev/null +++ b/backend/src/main/java/reviewme/config/WebConfig.java @@ -0,0 +1,16 @@ +package reviewme.config; + +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import reviewme.global.HeaderPropertyArgumentResolver; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new HeaderPropertyArgumentResolver()); + } +} diff --git a/backend/src/main/java/reviewme/config/properties/SwaggerProperties.java b/backend/src/main/java/reviewme/config/properties/SwaggerProperties.java new file mode 100644 index 000000000..babdf727b --- /dev/null +++ b/backend/src/main/java/reviewme/config/properties/SwaggerProperties.java @@ -0,0 +1,19 @@ +package reviewme.config.properties; + +import io.swagger.v3.oas.models.info.Info; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "docs.info") +public record SwaggerProperties( + String title, + String description, + String version +) { + + public Info swaggerInfo() { + return new Info() + .title(title) + .description(description) + .version(version); + } +} diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java new file mode 100644 index 000000000..e6fba936a --- /dev/null +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -0,0 +1,128 @@ +package reviewme.global; + +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.TypeMismatchException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.method.MethodValidationException; +import org.springframework.web.HttpMediaTypeException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.MissingRequestValueException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import reviewme.global.exception.BadRequestException; +import reviewme.global.exception.DataInconsistencyException; +import reviewme.global.exception.FieldErrorResponse; +import reviewme.global.exception.NotFoundException; +import reviewme.global.exception.UnauthorizedException; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ProblemDetail handleNotFoundException(NotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getErrorMessage()); + } + + @ExceptionHandler(BadRequestException.class) + public ProblemDetail handleBadRequestException(BadRequestException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getErrorMessage()); + } + + @ExceptionHandler(UnexpectedRequestException.class) + public ProblemDetail handleUnexpectedRequestException(UnexpectedRequestException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getErrorMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ProblemDetail handleUnauthorizedException(UnauthorizedException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getErrorMessage()); + } + + @ExceptionHandler(DataInconsistencyException.class) + public ProblemDetail handleDataConsistencyException(DataInconsistencyException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrorMessage()); + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleException(Exception ex) { + log.error("Initial server error has occurred", ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러가 발생했습니다."); + } + + // Following exceptions are exceptions that occur in Spring + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ProblemDetail handleHttpRequestMethodNotSupportedException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."); + } + + @ExceptionHandler(HttpMediaTypeException.class) + public ProblemDetail handleHttpMediaTypeException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "잘못된 media type 입니다."); + } + + @ExceptionHandler({MissingRequestValueException.class, MissingServletRequestPartException.class}) + public ProblemDetail handleMissingRequestException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "필수 요청 데이터가 누락되었습니다."); + } + + @ExceptionHandler({ServletRequestBindingException.class, HttpMessageNotReadableException.class}) + public ProblemDetail handleServletRequestBindingException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "요청을 읽을 수 없습니다."); + } + + @ExceptionHandler({ + MethodValidationException.class, BindException.class, + TypeMismatchException.class, HandlerMethodValidationException.class + }) + public ProblemDetail handleRequestFormatException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "요청의 형식이 잘못되었습니다."); + } + + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) + public ProblemDetail handleNoHandlerFoundException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "잘못된 경로의 요청입니다."); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + logSpringException(ex); + List fieldErrors = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(fieldError -> new FieldErrorResponse( + fieldError.getField(), + fieldError.getRejectedValue(), + fieldError.getDefaultMessage())) + .toList(); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다." + ); + Map properties = Map.of("fieldErrors", fieldErrors); + problemDetail.setProperties(properties); + return problemDetail; + } + + private void logSpringException(Exception ex) { + log.info("Spring error has occurred - {}: {}", ex.getClass().getSimpleName(), ex.getLocalizedMessage()); + } +} diff --git a/backend/src/main/java/reviewme/global/HeaderProperty.java b/backend/src/main/java/reviewme/global/HeaderProperty.java new file mode 100644 index 000000000..86462c596 --- /dev/null +++ b/backend/src/main/java/reviewme/global/HeaderProperty.java @@ -0,0 +1,18 @@ +package reviewme.global; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface HeaderProperty { + + @AliasFor("headerName") + String value() default ""; + + @AliasFor("value") + String headerName() default ""; +} diff --git a/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java b/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java new file mode 100644 index 000000000..5c825e3de --- /dev/null +++ b/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java @@ -0,0 +1,32 @@ +package reviewme.global; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import reviewme.global.exception.MissingHeaderPropertyException; + +public class HeaderPropertyArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(HeaderProperty.class); + } + + @Override + public String resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + HeaderProperty parameterAnnotation = parameter.getParameterAnnotation(HeaderProperty.class); + String headerName = parameterAnnotation.headerName(); + String headerProperty = request.getHeader(headerName); + + if (headerProperty == null) { + throw new MissingHeaderPropertyException(headerName); + } + return headerProperty; + } +} diff --git a/backend/src/main/java/reviewme/global/exception/BadRequestException.java b/backend/src/main/java/reviewme/global/exception/BadRequestException.java new file mode 100644 index 000000000..b53263987 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class BadRequestException extends ReviewMeException { + + protected BadRequestException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java b/backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java new file mode 100644 index 000000000..1f91caeff --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class DataInconsistencyException extends ReviewMeException { + + protected DataInconsistencyException(String errorMessage) { + super(errorMessage); + log.error("", this); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java b/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java new file mode 100644 index 000000000..e44edf619 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public record FieldErrorResponse( + String field, + Object value, + String message +) { +} diff --git a/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java b/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java new file mode 100644 index 000000000..8fc4dd76f --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MissingHeaderPropertyException extends BadRequestException { + + public MissingHeaderPropertyException(String headerName) { + super("요청에 %s이(가) 존재하지 않아요.".formatted(headerName)); + log.info("Missing header property: {}", headerName); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/NotFoundException.java b/backend/src/main/java/reviewme/global/exception/NotFoundException.java new file mode 100644 index 000000000..44bb4ac5f --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class NotFoundException extends ReviewMeException { + + protected NotFoundException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/ReviewMeException.java b/backend/src/main/java/reviewme/global/exception/ReviewMeException.java new file mode 100644 index 000000000..1d2c2e26e --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/ReviewMeException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public abstract class ReviewMeException extends RuntimeException { + + private final String errorMessage; +} diff --git a/backend/src/main/java/reviewme/global/exception/UnauthorizedException.java b/backend/src/main/java/reviewme/global/exception/UnauthorizedException.java new file mode 100644 index 000000000..150fc998b --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class UnauthorizedException extends ReviewMeException { + + protected UnauthorizedException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java b/backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java new file mode 100644 index 000000000..1267cdc74 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class UnexpectedRequestException extends ReviewMeException { + + protected UnexpectedRequestException(String errorMessage) { + super(errorMessage); + log.warn("", this); + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionGroup.java b/backend/src/main/java/reviewme/question/domain/OptionGroup.java new file mode 100644 index 000000000..61aa3d23a --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/OptionGroup.java @@ -0,0 +1,39 @@ +package reviewme.question.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "option_group") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class OptionGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "question_id", nullable = false) + private long questionId; + + @Column(name = "min_selection_count", nullable = false) + private int minSelectionCount; + + @Column(name = "max_selection_count", nullable = false) + private int maxSelectionCount; + + public OptionGroup(long questionId, int minSelectionCount, int maxSelectionCount) { + this.questionId = questionId; + this.minSelectionCount = minSelectionCount; + this.maxSelectionCount = maxSelectionCount; + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionItem.java b/backend/src/main/java/reviewme/question/domain/OptionItem.java new file mode 100644 index 000000000..59b29bc3b --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/OptionItem.java @@ -0,0 +1,46 @@ +package reviewme.question.domain; + +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.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "option_item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class OptionItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "option_group_id", nullable = false) + private long optionGroupId; + + @Column(name = "position", nullable = false) + private int position; + + @Column(name = "option_type", nullable = false) + @Enumerated(EnumType.STRING) + private OptionType optionType; + + public OptionItem(String content, long optionGroupId, int position, OptionType optionType) { + this.content = content; + this.optionGroupId = optionGroupId; + this.position = position; + this.optionType = optionType; + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionType.java b/backend/src/main/java/reviewme/question/domain/OptionType.java new file mode 100644 index 000000000..dfa86920b --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/OptionType.java @@ -0,0 +1,6 @@ +package reviewme.question.domain; + +public enum OptionType { + CATEGORY, + KEYWORD, +} diff --git a/backend/src/main/java/reviewme/question/domain/Question.java b/backend/src/main/java/reviewme/question/domain/Question.java new file mode 100644 index 000000000..584f05215 --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/Question.java @@ -0,0 +1,69 @@ +package reviewme.question.domain; + +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.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "question") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Question { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "required", nullable = false) + private boolean required; + + @Column(name = "question_type", nullable = false) + @Enumerated(EnumType.STRING) + private QuestionType questionType; + + @Column(name = "content", nullable = false, length = 1_000) + private String content; + + @Column(name = "guideline", nullable = true, length = 1_000) + private String guideline; + + @Column(name = "position", nullable = false) + private int position; + + public Question(boolean required, QuestionType questionType, String content, String guideline, int position) { + this.required = required; + this.questionType = questionType; + this.content = content; + this.guideline = guideline; + this.position = position; + } + + public boolean isSelectable() { + return questionType == QuestionType.CHECKBOX; + } + + public boolean hasGuideline() { + return guideline != null && !guideline.isEmpty(); + } + + public String convertContent(String target, String replacement) { + return content.replace(target, replacement); + } + + public String convertGuideLine(String target, String replacement) { + if (guideline == null) { + return null; + } + return guideline.replace(target, replacement); + } +} diff --git a/backend/src/main/java/reviewme/question/domain/QuestionType.java b/backend/src/main/java/reviewme/question/domain/QuestionType.java new file mode 100644 index 000000000..863ba56e5 --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/QuestionType.java @@ -0,0 +1,8 @@ +package reviewme.question.domain; + +public enum QuestionType { + CHECKBOX, + TEXT, + ; + +} diff --git a/backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java b/backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java new file mode 100644 index 000000000..a9a016b91 --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java @@ -0,0 +1,13 @@ +package reviewme.question.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class MissingOptionItemsInOptionGroupException extends DataInconsistencyException { + + public MissingOptionItemsInOptionGroupException(long optionGroupId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("OptionGroup has no OptionItems - optionGroupId: {}", optionGroupId); + } +} diff --git a/backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java b/backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java new file mode 100644 index 000000000..a76e9e3ba --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.question.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class QuestionNotFoundException extends NotFoundException { + + public QuestionNotFoundException(long questionId) { + super("질문이 존재하지 않아요."); + log.warn("Question not found - questionId: {}", questionId, this); + } +} diff --git a/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java b/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java new file mode 100644 index 000000000..1be923085 --- /dev/null +++ b/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java @@ -0,0 +1,12 @@ +package reviewme.question.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.question.domain.OptionGroup; + +@Repository +public interface OptionGroupRepository extends JpaRepository { + + Optional findByQuestionId(long questionId); +} diff --git a/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java b/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java new file mode 100644 index 000000000..0b639b6fb --- /dev/null +++ b/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java @@ -0,0 +1,48 @@ +package reviewme.question.repository; + +import java.util.List; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; + +@Repository +public interface OptionItemRepository extends JpaRepository { + + List findAllByOptionGroupId(long optionGroupId); + + @Query(value = """ + SELECT o.id FROM option_item o + LEFT JOIN checkbox_answer_selected_option c + ON c.selected_option_id = o.id + LEFT JOIN checkbox_answer ca + ON c.checkbox_answer_id = ca.id + WHERE ca.review_id = :reviewId + """, nativeQuery = true) + Set findSelectedOptionItemIdsByReviewId(long reviewId); + + @Query(value = """ + SELECT o.* FROM option_item o + LEFT JOIN checkbox_answer_selected_option c + ON c.selected_option_id = o.id + LEFT JOIN checkbox_answer ca + ON c.checkbox_answer_id = ca.id + WHERE ca.review_id = :reviewId + AND ca.question_id = :questionId + ORDER BY o.position ASC + """, nativeQuery = true) + List findSelectedOptionItemsByReviewIdAndQuestionId(long reviewId, long questionId); + + @Query(value = """ + SELECT o.* FROM option_item o + INNER JOIN checkbox_answer_selected_option cao + ON cao.selected_option_id = o.id + INNER JOIN checkbox_answer ca + ON cao.checkbox_answer_id = ca.id + WHERE ca.review_id = :reviewId + AND o.option_type = :#{#optionType.name()} + """, nativeQuery = true) + List findByReviewIdAndOptionType(long reviewId, OptionType optionType); +} diff --git a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java new file mode 100644 index 000000000..d3e1816c9 --- /dev/null +++ b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java @@ -0,0 +1,29 @@ +package reviewme.question.repository; + +import java.util.List; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import reviewme.question.domain.Question; + +public interface QuestionRepository extends JpaRepository { + + @Query(value = """ + SELECT q.* FROM question q + LEFT JOIN section_question sq + ON sq.question_id = q.id + WHERE sq.section_id = :sectionId + ORDER BY q.position ASC + """, nativeQuery = true) + List findAllBySectionId(long sectionId); + + @Query(value = """ + SELECT q.id FROM question q + LEFT JOIN section_question sq + ON sq.question_id = q.id + LEFT JOIN template_section ts + ON sq.section_id = ts.section_id + WHERE ts.template_id = :templateId + """, nativeQuery = true) + Set findAllQuestionIdByTemplateId(long templateId); +} diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java new file mode 100644 index 000000000..4871f7377 --- /dev/null +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -0,0 +1,57 @@ +package reviewme.review.controller; + +import jakarta.validation.Valid; +import java.net.URI; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reviewme.global.HeaderProperty; +import reviewme.review.service.CreateReviewService; +import reviewme.review.service.ReviewDetailLookupService; +import reviewme.review.service.ReviewService; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; + +@RestController +@RequiredArgsConstructor +public class ReviewController { + + private static final String GROUP_ACCESS_CODE_HEADER = "GroupAccessCode"; + + private final CreateReviewService createReviewService; + private final ReviewService reviewService; + private final ReviewDetailLookupService reviewDetailLookupService; + + @PostMapping("/v2/reviews") + public ResponseEntity createReview(@Valid @RequestBody CreateReviewRequest request) { + long savedReviewId = createReviewService.createReview(request); + return ResponseEntity.created(URI.create("/reviews/" + savedReviewId)).build(); + } + + @GetMapping("/v2/reviews") + public ResponseEntity findReceivedReviews( + @RequestParam String reviewRequestCode, + @HeaderProperty(GROUP_ACCESS_CODE_HEADER) String groupAccessCode + ) { + ReceivedReviewsResponse response = reviewService.findReceivedReviews(reviewRequestCode, groupAccessCode); + return ResponseEntity.ok(response); + } + + @GetMapping("/v2/reviews/{id}") + public ResponseEntity findReceivedReviewDetail( + @PathVariable long id, + @RequestParam String reviewRequestCode, + @HeaderProperty(GROUP_ACCESS_CODE_HEADER) String groupAccessCode + ) { + TemplateAnswerResponse response = reviewDetailLookupService.getReviewDetail( + id, reviewRequestCode, groupAccessCode + ); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java b/backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java new file mode 100644 index 000000000..8a19dc049 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java @@ -0,0 +1,32 @@ +package reviewme.review.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "checkbox_answer_selected_option") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class CheckBoxAnswerSelectedOption { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "checkbox_answer_id", nullable = false, insertable = false, updatable = false) + private long checkboxAnswerId; + + @Column(name = "selected_option_id", nullable = false) + private long selectedOptionId; + + public CheckBoxAnswerSelectedOption(long selectedOptionId) { + this.selectedOptionId = selectedOptionId; + } +} diff --git a/backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java b/backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java new file mode 100644 index 000000000..6f6cf5aab --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java @@ -0,0 +1,46 @@ +package reviewme.review.domain; + +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.OneToMany; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "checkbox_answer") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class CheckboxAnswer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false, insertable = false, updatable = false) + private long reviewId; + + @Column(name = "question_id", nullable = false) + private long questionId; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "checkbox_answer_id", nullable = false, updatable = false) + private List selectedOptionIds; + + public CheckboxAnswer(long questionId, List selectedOptionIds) { + this.questionId = questionId; + this.selectedOptionIds = selectedOptionIds.stream() + .map(CheckBoxAnswerSelectedOption::new) + .toList(); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java b/backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java new file mode 100644 index 000000000..bc2447f5d --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java @@ -0,0 +1,28 @@ +package reviewme.review.domain; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import reviewme.review.domain.exception.MissingCheckboxAnswerForQuestionException; + +public class CheckboxAnswers { + + private final Map checkboxAnswers; + + public CheckboxAnswers(List checkboxAnswers) { + this.checkboxAnswers = checkboxAnswers.stream() + .collect(Collectors.toMap(CheckboxAnswer::getQuestionId, Function.identity())); + } + + public CheckboxAnswer getAnswerByQuestionId(long questionId) { + if (!checkboxAnswers.containsKey(questionId)) { + throw new MissingCheckboxAnswerForQuestionException(questionId); + } + return checkboxAnswers.get(questionId); + } + + public boolean hasAnswerByQuestionId(long questionId) { + return checkboxAnswers.containsKey(questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/Review.java b/backend/src/main/java/reviewme/review/domain/Review.java new file mode 100644 index 000000000..4bf4a6856 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/Review.java @@ -0,0 +1,61 @@ +package reviewme.review.domain; + +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.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "review") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_id", nullable = false) + private long templateId; + + @Column(name = "review_group_id", nullable = false) + private long reviewGroupId; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) + @JoinColumn(name = "review_id", nullable = false, updatable = false) + private List textAnswers; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) + @JoinColumn(name = "review_id", nullable = false, updatable = false) + private List checkboxAnswers; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + public Review(long templateId, long reviewGroupId, + List textAnswers, List checkboxAnswers) { + this.templateId = templateId; + this.reviewGroupId = reviewGroupId; + this.textAnswers = textAnswers; + this.checkboxAnswers = checkboxAnswers; + this.createdAt = LocalDateTime.now(); + } + + public LocalDate getCreatedDate() { + return createdAt.toLocalDate(); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/TextAnswer.java b/backend/src/main/java/reviewme/review/domain/TextAnswer.java new file mode 100644 index 000000000..ac54530a9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/TextAnswer.java @@ -0,0 +1,35 @@ +package reviewme.review.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "text_answer") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class TextAnswer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "question_id", nullable = false) + private long questionId; + + @Column(name = "content", nullable = false, length = 5000) + private String content; + + public TextAnswer(long questionId, String content) { + this.questionId = questionId; + this.content = content; + } +} diff --git a/backend/src/main/java/reviewme/review/domain/TextAnswers.java b/backend/src/main/java/reviewme/review/domain/TextAnswers.java new file mode 100644 index 000000000..4ce230eb0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/TextAnswers.java @@ -0,0 +1,30 @@ +package reviewme.review.domain; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import reviewme.review.domain.exception.MissingTextAnswerForQuestionException; + +@Slf4j +public class TextAnswers { + + private final Map textAnswers; + + public TextAnswers(List textAnswers) { + this.textAnswers = textAnswers.stream() + .collect(Collectors.toMap(TextAnswer::getQuestionId, Function.identity())); + } + + public TextAnswer getAnswerByQuestionId(long questionId) { + if (!textAnswers.containsKey(questionId)) { + throw new MissingTextAnswerForQuestionException(questionId); + } + return textAnswers.get(questionId); + } + + public boolean hasAnswerByQuestionId(long questionId) { + return textAnswers.containsKey(questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java b/backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java new file mode 100644 index 000000000..bd50713ee --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class CategoryOptionByReviewNotFoundException extends NotFoundException { + + public CategoryOptionByReviewNotFoundException(long reviewId) { + super("리뷰에 선택한 카테고리가 없어요."); + log.warn("CategoryOptionNotFoundException is occured - reviewId: {}", reviewId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java new file mode 100644 index 000000000..457cebfac --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java @@ -0,0 +1,14 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidProjectNameLengthException extends BadRequestException { + + public InvalidProjectNameLengthException(int projectNameLength, int minLength, int maxLength) { + super("프로젝트 이름은 %d글자 이상 %d글자 이하여야 해요.".formatted(minLength, maxLength)); + log.warn("ProjectName is out of bound - projectNameLength:{}, minLength:{}, maxLength: {}", + projectNameLength, minLength, maxLength, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java new file mode 100644 index 000000000..89b802fcf --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +public class InvalidReviewAccessByReviewGroupException extends UnexpectedRequestException { + + public InvalidReviewAccessByReviewGroupException(long reviewId, long reviewGroupId) { + super("리뷰가 존재하지 않아요."); + log.warn("Review is not in review group - reviewId: {}, reviewGroupId: {}", reviewId, reviewGroupId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java new file mode 100644 index 000000000..0294685ef --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java @@ -0,0 +1,14 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidRevieweeNameLengthException extends BadRequestException { + + public InvalidRevieweeNameLengthException(int revieweeNameLength, int minLength, int maxLength) { + super("리뷰이 이름은 %d글자 이상 %d글자 이하여야 해요.".formatted(minLength, maxLength)); + log.warn("RevieweeName is out of bound - revieweeNameLength:{}, minLength:{}, maxLength: {}", + revieweeNameLength, minLength, maxLength, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java new file mode 100644 index 000000000..236531179 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java @@ -0,0 +1,14 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidTextAnswerLengthException extends BadRequestException { + + public InvalidTextAnswerLengthException(int answerLength, int minLength, int maxLength) { + super("답변의 길이는 %d자 이상 %d자 이하여야 해요.".formatted(minLength, maxLength)); + log.warn("AnswerLength is out of bound - answerLength: {}, minLength: {}, maxLength: {}", + answerLength, minLength, maxLength, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java b/backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java new file mode 100644 index 000000000..f64df7f25 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class MissingCheckboxAnswerForQuestionException extends NotFoundException { + + public MissingCheckboxAnswerForQuestionException(long questionId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Checkbox Answer not found for questionId: {}", questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java b/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java new file mode 100644 index 000000000..6ed567514 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class MissingTextAnswerForQuestionException extends DataInconsistencyException { + + public MissingTextAnswerForQuestionException(long questionId) { + super("질문에 해당하는 서술형 답변을 찾지 못했어요."); + log.error("The question is a text question but text answer not found for questionId: {}", questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java new file mode 100644 index 000000000..345fbe3a1 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewGroupNotFoundByGroupAccessCodeException extends NotFoundException { + + public ReviewGroupNotFoundByGroupAccessCodeException(String groupAccessCode) { + super("리뷰 그룹을 찾을 수 없어요."); + log.info("ReviewGroup not found by groupAccessCode - groupAccessCode: {}", groupAccessCode); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java new file mode 100644 index 000000000..6b8cb64fe --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewGroupNotFoundByReviewRequestCodeException extends NotFoundException { + + public ReviewGroupNotFoundByReviewRequestCodeException(String reviewRequestCode) { + super("리뷰 요청 코드에 대한 리뷰 그룹을 찾을 수 없어요."); + log.info("ReviewGroup not found by reviewRequestCode - reviewRequestCode: {}", reviewRequestCode); + } +} diff --git a/backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java b/backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java new file mode 100644 index 000000000..30ce44014 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java @@ -0,0 +1,9 @@ +package reviewme.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.CheckboxAnswer; + +@Repository +public interface CheckboxAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java new file mode 100644 index 000000000..1ab6a1cf3 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -0,0 +1,17 @@ +package reviewme.review.repository; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.Review; + +@Repository +public interface ReviewRepository extends JpaRepository { + + @Query("SELECT r FROM Review r WHERE r.reviewGroupId=:reviewGroupId ORDER BY r.createdAt DESC") + List findReceivedReviewsByGroupId(long reviewGroupId); + + Optional findByIdAndReviewGroupId(long reviewId, long reviewGroupId); +} diff --git a/backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java b/backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java new file mode 100644 index 000000000..0c3465399 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java @@ -0,0 +1,9 @@ +package reviewme.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.TextAnswer; + +@Repository +public interface TextAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java b/backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java new file mode 100644 index 000000000..d9a10a434 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java @@ -0,0 +1,77 @@ +package reviewme.review.service; + +import java.util.HashSet; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; +import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.template.domain.exception.OptionGroupNotFoundByQuestionIdException; + +@Component +@RequiredArgsConstructor +public class CreateCheckBoxAnswerRequestValidator { + + private final QuestionRepository questionRepository; + private final OptionGroupRepository optionGroupRepository; + private final OptionItemRepository optionItemRepository; + + public void validate(CreateReviewAnswerRequest request) { + validateNotContainingText(request); + Question question = questionRepository.findById(request.questionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(request.questionId())); + OptionGroup optionGroup = optionGroupRepository.findByQuestionId(question.getId()) + .orElseThrow(() -> new OptionGroupNotFoundByQuestionIdException(question.getId())); + validateRequiredQuestion(request, question); + validateOnlyIncludingProvidedOptionItem(request, optionGroup); + validateCheckedOptionItemCount(request, optionGroup); + } + + private void validateNotContainingText(CreateReviewAnswerRequest request) { + if (request.text() != null) { + throw new CheckBoxAnswerIncludedTextException(); + } + } + + private void validateRequiredQuestion(CreateReviewAnswerRequest request, Question question) { + if (question.isRequired() && request.selectedOptionIds() == null) { + throw new RequiredQuestionNotAnsweredException(question.getId()); + } + } + + private void validateOnlyIncludingProvidedOptionItem(CreateReviewAnswerRequest request, OptionGroup optionGroup) { + List providedOptionItemIds = optionItemRepository.findAllByOptionGroupId(optionGroup.getId()) + .stream() + .map(OptionItem::getId) + .toList(); + List submittedOptionItemIds = request.selectedOptionIds(); + + if (!new HashSet<>(providedOptionItemIds).containsAll(submittedOptionItemIds)) { + throw new CheckBoxAnswerIncludedNotProvidedOptionItemException( + request.questionId(), providedOptionItemIds, submittedOptionItemIds + ); + } + } + + private void validateCheckedOptionItemCount(CreateReviewAnswerRequest request, OptionGroup optionGroup) { + if (request.selectedOptionIds().size() < optionGroup.getMinSelectionCount() + || request.selectedOptionIds().size() > optionGroup.getMaxSelectionCount()) { + throw new SelectedOptionItemCountOutOfRangeException( + request.questionId(), + request.selectedOptionIds().size(), + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount() + ); + } + } +} diff --git a/backend/src/main/java/reviewme/review/service/CreateReviewService.java b/backend/src/main/java/reviewme/review/service/CreateReviewService.java new file mode 100644 index 000000000..d6225f133 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/CreateReviewService.java @@ -0,0 +1,144 @@ +package reviewme.review.service; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.exception.MissingRequiredQuestionException; +import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.review.service.exception.UnnecessaryQuestionIncludedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.SectionQuestion; +import reviewme.template.domain.Template; +import reviewme.template.domain.exception.TemplateNotFoundByReviewGroupException; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@Service +@RequiredArgsConstructor +public class CreateReviewService { + + private final ReviewRepository reviewRepository; + private final QuestionRepository questionRepository; + private final ReviewGroupRepository reviewGroupRepository; + private final CreateTextAnswerRequestValidator createTextAnswerRequestValidator; + private final CreateCheckBoxAnswerRequestValidator createCheckBoxAnswerRequestValidator; + private final TemplateRepository templateRepository; + private final SectionRepository sectionRepository; + + @Transactional + public long createReview(CreateReviewRequest request) { + ReviewGroup reviewGroup = validateReviewGroupByRequestCode(request.reviewRequestCode()); + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId())); + validateSubmittedQuestionsContainedInTemplate(reviewGroup.getTemplateId(), request); + validateOnlyRequiredQuestionsSubmitted(template, request); + + return saveReview(request, reviewGroup); + } + + private ReviewGroup validateReviewGroupByRequestCode(String reviewRequestCode) { + return reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + } + + private void validateSubmittedQuestionsContainedInTemplate(long templateId, CreateReviewRequest request) { + Set providedQuestionIds = questionRepository.findAllQuestionIdByTemplateId(templateId); + Set submittedQuestionIds = request.answers() + .stream() + .map(CreateReviewAnswerRequest::questionId) + .collect(Collectors.toSet()); + if (!providedQuestionIds.containsAll(submittedQuestionIds)) { + throw new SubmittedQuestionAndProvidedQuestionMismatchException(submittedQuestionIds, providedQuestionIds); + } + } + + private void validateOnlyRequiredQuestionsSubmitted(Template template, CreateReviewRequest request) { + // 제출된 리뷰의 옵션 아이템 ID 목록 + List selectedOptionItemIds = request.answers() + .stream() + .filter(answer -> answer.selectedOptionIds() != null) + .flatMap(answer -> answer.selectedOptionIds().stream()) + .toList(); + + // 제출된 리뷰의 질문 ID 목록 + List submittedQuestionIds = request.answers() + .stream() + .map(CreateReviewAnswerRequest::questionId) + .toList(); + + // 섹션에서 답해야 할 질문 ID 목록 + List requiredQuestionIdsCandidates = sectionRepository.findAllByTemplateId(template.getId()) + .stream() + // 선택된 optionItem 에 따라 required 를 다르게 책정해서 필터링 + .filter(section -> section.isVisibleBySelectedOptionIds(selectedOptionItemIds)) + .flatMap(section -> section.getQuestionIds().stream()) + .map(SectionQuestion::getQuestionId) + .toList(); + List requiredQuestionIds = questionRepository.findAllById(requiredQuestionIdsCandidates) + .stream() + .filter(Question::isRequired) + .map(Question::getId) + .toList(); + + // 제출된 리뷰의 질문 중에서 제출해야 할 질문이 모두 포함되었는지 검사 + Set submittedQuestionIds2 = new HashSet<>(submittedQuestionIds); + if (!submittedQuestionIds2.containsAll(requiredQuestionIds)) { + List missingRequiredQuestionIds = new ArrayList<>(requiredQuestionIds); + missingRequiredQuestionIds.removeAll(submittedQuestionIds2); + throw new MissingRequiredQuestionException(missingRequiredQuestionIds); + } + + // 제출된 리뷰의 질문 중에서 필수가 아닌 질문이 포함되었는지 검사 + requiredQuestionIds.forEach(submittedQuestionIds2::remove); + List unnecessaryQuestionIds = questionRepository.findAllById(submittedQuestionIds2) + .stream() + .filter(Question::isRequired) + .map(Question::getId) + .toList(); + if (!unnecessaryQuestionIds.isEmpty()) { + throw new UnnecessaryQuestionIncludedException(unnecessaryQuestionIds); + } + } + + private Long saveReview(CreateReviewRequest request, ReviewGroup reviewGroup) { + List textAnswers = new ArrayList<>(); + List checkboxAnswers = new ArrayList<>(); + for (CreateReviewAnswerRequest answerRequests : request.answers()) { + Question question = questionRepository.findById(answerRequests.questionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(answerRequests.questionId())); + QuestionType questionType = question.getQuestionType(); + if (questionType == QuestionType.TEXT && answerRequests.isNotBlank()) { + createTextAnswerRequestValidator.validate(answerRequests); + textAnswers.add(new TextAnswer(question.getId(), answerRequests.text())); + continue; + } + if (questionType == QuestionType.CHECKBOX) { + createCheckBoxAnswerRequestValidator.validate(answerRequests); + checkboxAnswers.add(new CheckboxAnswer(question.getId(), answerRequests.selectedOptionIds())); + } + } + + Review savedReview = reviewRepository.save( + new Review(reviewGroup.getTemplateId(), reviewGroup.getId(), textAnswers, checkboxAnswers) + ); + return savedReview.getId(); + } +} diff --git a/backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java b/backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java new file mode 100644 index 000000000..f47d03852 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java @@ -0,0 +1,48 @@ +package reviewme.review.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.exception.InvalidTextAnswerLengthException; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; + +@Component +@RequiredArgsConstructor +public class CreateTextAnswerRequestValidator { + + private static final int MIN_LENGTH = 20; + private static final int MAX_LENGTH = 1_000; + + private final QuestionRepository questionRepository; + + public void validate(CreateReviewAnswerRequest request) { + Question question = questionRepository.findById(request.questionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(request.questionId())); + validateNotIncludingOptions(request); + validateQuestionRequired(question, request); + validateLength(request); + } + + private void validateNotIncludingOptions(CreateReviewAnswerRequest request) { + if (request.selectedOptionIds() != null) { + throw new TextAnswerIncludedOptionItemException(); + } + } + + private void validateQuestionRequired(Question question, CreateReviewAnswerRequest request) { + if (question.isRequired() && request.text() == null) { + throw new RequiredQuestionNotAnsweredException(question.getId()); + } + } + + private void validateLength(CreateReviewAnswerRequest request) { + int textLength = request.text().length(); + if (textLength < MIN_LENGTH || textLength > MAX_LENGTH) { + throw new InvalidTextAnswerLengthException(textLength, MIN_LENGTH, MAX_LENGTH); + } + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java new file mode 100644 index 000000000..eb9fb04f9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java @@ -0,0 +1,155 @@ +package reviewme.review.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswers; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.domain.TextAnswers; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.detail.OptionGroupAnswerResponse; +import reviewme.review.service.dto.response.detail.OptionItemAnswerResponse; +import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.review.service.exception.ReviewNotFoundByIdAndGroupException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.exception.OptionGroupNotFoundByQuestionIdException; +import reviewme.template.repository.SectionRepository; + +@Service +@Transactional(readOnly = true) +@AllArgsConstructor +public class ReviewDetailLookupService { + + private final SectionRepository sectionRepository; + private final ReviewRepository reviewRepository; + private final ReviewGroupRepository reviewGroupRepository; + private final QuestionRepository questionRepository; + private final OptionItemRepository optionItemRepository; + private final OptionGroupRepository optionGroupRepository; + + public TemplateAnswerResponse getReviewDetail(long reviewId, String reviewRequestCode, String groupAccessCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + if (!reviewGroup.matchesGroupAccessCode(groupAccessCode)) { + throw new ReviewGroupUnauthorizedException(reviewGroup.getId()); + } + Review review = reviewRepository.findByIdAndReviewGroupId(reviewId, reviewGroup.getId()) + .orElseThrow(() -> new ReviewNotFoundByIdAndGroupException(reviewId, reviewGroup.getId())); + + long templateId = review.getTemplateId(); + + List
sections = sectionRepository.findAllByTemplateId(templateId); + List sectionResponses = new ArrayList<>(); + + for (Section section : sections) { + addSectionResponse(review, reviewGroup, section, sectionResponses); + } + + return new TemplateAnswerResponse( + templateId, + reviewGroup.getReviewee(), + reviewGroup.getProjectName(), + review.getCreatedDate(), + sectionResponses + ); + } + + private void addSectionResponse(Review review, ReviewGroup reviewGroup, + Section section, List sectionResponses) { + ArrayList questionResponses = new ArrayList<>(); + + for (Question question : questionRepository.findAllBySectionId(section.getId())) { + if (question.isSelectable()) { + addCheckboxQuestionResponse(review, reviewGroup, question, questionResponses); + } else { + addTextQuestionResponse(review, reviewGroup, question, questionResponses); + } + } + + if (!questionResponses.isEmpty()) { + sectionResponses.add(new SectionAnswerResponse( + section.getId(), + section.convertHeader("{revieweeName}", reviewGroup.getReviewee()), + questionResponses + )); + } + } + + private void addCheckboxQuestionResponse(Review review, ReviewGroup reviewGroup, + Question question, ArrayList questionResponses) { + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(review.getCheckboxAnswers()); + + if (checkboxAnswers.hasAnswerByQuestionId(question.getId())) { + questionResponses.add(getCheckboxAnswerResponse(review, question, reviewGroup)); + } + + } + + private void addTextQuestionResponse(Review review, ReviewGroup reviewGroup, + Question question, ArrayList questionResponses) { + TextAnswers textAnswers = new TextAnswers(review.getTextAnswers()); + + if (textAnswers.hasAnswerByQuestionId(question.getId())) { + questionResponses.add(getTextAnswerResponse(textAnswers, question, reviewGroup)); + } + } + + private QuestionAnswerResponse getCheckboxAnswerResponse(Review review, Question question, + ReviewGroup reviewGroup) { + OptionGroup optionGroup = optionGroupRepository.findByQuestionId(question.getId()) + .orElseThrow(() -> new OptionGroupNotFoundByQuestionIdException(question.getId())); + Set selectedOptionItemIds = optionItemRepository.findSelectedOptionItemIdsByReviewId(review.getId()); + List optionItemResponse = + optionItemRepository.findSelectedOptionItemsByReviewIdAndQuestionId(review.getId(), question.getId()) + .stream() + .map(optionItem -> new OptionItemAnswerResponse( + optionItem.getId(), + optionItem.getContent(), + selectedOptionItemIds.contains(optionItem.getId())) + ).toList(); + + OptionGroupAnswerResponse optionGroupAnswerResponse = new OptionGroupAnswerResponse( + optionGroup.getId(), + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount(), + optionItemResponse + ); + + return new QuestionAnswerResponse( + question.getId(), + question.isRequired(), + question.getQuestionType(), + question.convertContent("{revieweeName}", reviewGroup.getReviewee()), + optionGroupAnswerResponse, + null + ); + } + + private QuestionAnswerResponse getTextAnswerResponse(TextAnswers textAnswers, Question question, + ReviewGroup reviewGroup) { + TextAnswer textAnswer = textAnswers.getAnswerByQuestionId(question.getId()); + return new QuestionAnswerResponse( + question.getId(), + question.isRequired(), + question.getQuestionType(), + question.convertContent("{revieweeName}", reviewGroup.getReviewee()), + null, + textAnswer.getContent() + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java b/backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java new file mode 100644 index 000000000..d0d49781d --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java @@ -0,0 +1,20 @@ +package reviewme.review.service; + +import java.util.List; +import reviewme.review.domain.TextAnswer; + +public class ReviewPreviewGenerator { + + private static final int PREVIEW_LENGTH = 150; + + public String generatePreview(List reviewTextAnswers) { + if (reviewTextAnswers.isEmpty()) { + return ""; + } + String answer = reviewTextAnswers.get(0).getContent(); + if (answer.length() > PREVIEW_LENGTH) { + return answer.substring(0, PREVIEW_LENGTH); + } + return answer; + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewService.java b/backend/src/main/java/reviewme/review/service/ReviewService.java new file mode 100644 index 000000000..1ac3d0b3b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewService.java @@ -0,0 +1,63 @@ +package reviewme.review.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.repository.OptionItemRepository; +import reviewme.review.domain.Review; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.list.ReceivedReviewCategoryResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewGroupRepository reviewGroupRepository; + private final OptionItemRepository optionItemRepository; + private final ReviewRepository reviewRepository; + + private final ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); + + @Transactional(readOnly = true) + public ReceivedReviewsResponse findReceivedReviews(String reviewRequestCode, String groupAccessCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + if (!reviewGroup.matchesGroupAccessCode(groupAccessCode)) { + throw new ReviewGroupUnauthorizedException(reviewGroup.getId()); + } + + List reviewResponses = + reviewRepository.findReceivedReviewsByGroupId(reviewGroup.getId()) + .stream() + .map(this::createReceivedReviewResponse) + .toList(); + + return new ReceivedReviewsResponse(reviewGroup.getReviewee(), reviewGroup.getProjectName(), reviewResponses); + } + + private ReceivedReviewResponse createReceivedReviewResponse(Review review) { + List categoryOptionItems = optionItemRepository.findByReviewIdAndOptionType(review.getId(), + OptionType.CATEGORY); + + List categoryResponses = categoryOptionItems.stream() + .map(optionItem -> new ReceivedReviewCategoryResponse(optionItem.getId(), optionItem.getContent())) + .toList(); + + return new ReceivedReviewResponse( + review.getId(), + review.getCreatedAt().toLocalDate(), + reviewPreviewGenerator.generatePreview(review.getTextAnswers()), + categoryResponses + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java new file mode 100644 index 000000000..32ee6b238 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java @@ -0,0 +1,21 @@ +package reviewme.review.service.dto.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record CreateReviewAnswerRequest( + + @NotNull(message = "질문 ID를 입력해주세요.") + Long questionId, + + @Nullable + List selectedOptionIds, + + @Nullable + String text +) { + public boolean isNotBlank() { + return text != null && !text.isBlank(); + } +} diff --git a/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java new file mode 100644 index 000000000..bfb47b769 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java @@ -0,0 +1,15 @@ +package reviewme.review.service.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record CreateReviewRequest( + + @NotBlank(message = "리뷰 요청 코드를 입력해주세요.") + String reviewRequestCode, + + @NotEmpty(message = "답변 내용을 입력해주세요.") + List answers +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java new file mode 100644 index 000000000..894dbaae8 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java @@ -0,0 +1,11 @@ +package reviewme.review.service.dto.response.detail; + +import java.util.List; + +public record OptionGroupAnswerResponse( + long optionGroupId, + long minCount, + long maxCount, + List options +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java new file mode 100644 index 000000000..6bd424f5f --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java @@ -0,0 +1,8 @@ +package reviewme.review.service.dto.response.detail; + +public record OptionItemAnswerResponse( + long optionId, + String content, + boolean isChecked +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java new file mode 100644 index 000000000..000eb83c8 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java @@ -0,0 +1,14 @@ +package reviewme.review.service.dto.response.detail; + +import jakarta.annotation.Nullable; +import reviewme.question.domain.QuestionType; + +public record QuestionAnswerResponse( + long questionId, + boolean required, + QuestionType questionType, + String content, + @Nullable OptionGroupAnswerResponse optionGroup, + @Nullable String answer +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java new file mode 100644 index 000000000..ad2887644 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java @@ -0,0 +1,10 @@ +package reviewme.review.service.dto.response.detail; + +import java.util.List; + +public record SectionAnswerResponse( + long sectionId, + String header, + List questions +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java new file mode 100644 index 000000000..0e838236b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java @@ -0,0 +1,13 @@ +package reviewme.review.service.dto.response.detail; + +import java.time.LocalDate; +import java.util.List; + +public record TemplateAnswerResponse( + long formId, + String revieweeName, + String projectName, + LocalDate createdAt, + List sections +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java new file mode 100644 index 000000000..298e78faa --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java @@ -0,0 +1,7 @@ +package reviewme.review.service.dto.response.list; + +public record ReceivedReviewCategoryResponse( + long optionId, + String content +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java new file mode 100644 index 000000000..fa6804c18 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java @@ -0,0 +1,12 @@ +package reviewme.review.service.dto.response.list; + +import java.time.LocalDate; +import java.util.List; + +public record ReceivedReviewResponse( + long reviewId, + LocalDate createdAt, + String contentPreview, + List categories +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java new file mode 100644 index 000000000..877b3a0de --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java @@ -0,0 +1,10 @@ +package reviewme.review.service.dto.response.list; + +import java.util.List; + +public record ReceivedReviewsResponse( + String revieweeName, + String projectName, + List reviews +) { +} diff --git a/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java new file mode 100644 index 000000000..a7d7e04b0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java @@ -0,0 +1,17 @@ +package reviewme.review.service.exception; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +public class CheckBoxAnswerIncludedNotProvidedOptionItemException extends UnexpectedRequestException { + + public CheckBoxAnswerIncludedNotProvidedOptionItemException(long questionId, + List providedOptionIds, + List submittedOptionIds) { + super("제공되는 선택지에 없는 선택지를 응답했어요."); + log.warn("Answer included not provided options - questionId:{}, providedOptionIds: {}, submittedOptionIds: {}", + questionId, providedOptionIds, submittedOptionIds, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java new file mode 100644 index 000000000..f3a7843b6 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class CheckBoxAnswerIncludedTextException extends BadRequestException { + + public CheckBoxAnswerIncludedTextException() { + super("체크박스형 응답은 텍스트를 포함할 수 없어요."); + log.warn("CheckBox type answer cannot have option items"); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java b/backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java new file mode 100644 index 000000000..efac7de80 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java @@ -0,0 +1,15 @@ +package reviewme.review.service.exception; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class MissingRequiredQuestionException extends BadRequestException { + + public MissingRequiredQuestionException(List missingRequiredQuestionIds) { + super("필수 질문을 제출하지 않았어요."); + log.warn("Required question is not submitted. Missing Required questionIds: {}", + missingRequiredQuestionIds, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java b/backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java new file mode 100644 index 000000000..517354d35 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class OptionItemNotFoundBySelectedOptionId extends DataInconsistencyException { + + public OptionItemNotFoundBySelectedOptionId(long selectedOptionId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Submitted checkBox's option item is not exist in database - selectedOptionId: {}", selectedOptionId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java b/backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java new file mode 100644 index 000000000..0367b93f6 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class RequiredQuestionNotAnsweredException extends BadRequestException { + + public RequiredQuestionNotAnsweredException(long questionId) { + super("필수 질문의 답변을 작성하지 않았어요."); + log.warn("Required question must be answered - questionId: {}", questionId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java new file mode 100644 index 000000000..7fa65044b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java @@ -0,0 +1,14 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class ReviewGroupNotFoundByCodesException extends BadRequestException { + + public ReviewGroupNotFoundByCodesException(String reviewRequestCode, String groupAccessCode) { + super("인증 정보에 해당하는 리뷰 확인 코드와 리뷰 요청 코드를 통해 찾을 수 있는 리뷰 그룹이 없어요."); + log.info("ReviewGroup not found by codes - reviewRequestCode: {}, groupAccessCode: {}", + reviewRequestCode, groupAccessCode); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java new file mode 100644 index 000000000..e18bd7e34 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnauthorizedException; + +@Slf4j +public class ReviewGroupUnauthorizedException extends UnauthorizedException { + + public ReviewGroupUnauthorizedException(long reviewGroupId) { + super("리뷰를 확인할 권한이 없어요."); + log.info("Group access code mismatch on review group: {}", reviewGroupId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java new file mode 100644 index 000000000..11cbc93e5 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewNotFoundByIdAndGroupException extends NotFoundException { + + public ReviewNotFoundByIdAndGroupException(long reviewId, long reviewGroupId) { + super("리뷰를 찾을 수 없어요"); + log.info("Review not found from group - reviewGroupId: {}, reviewId: {}", reviewGroupId, reviewId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java new file mode 100644 index 000000000..ed4d79c00 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewNotFoundException extends NotFoundException { + + public ReviewNotFoundException(String reviewRequestCode, long reviewId) { + super("리뷰가 존재하지 않아요."); + log.info("Review not found: reviewRequestCode: {}, reviewId: {}", reviewRequestCode, reviewId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java b/backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java new file mode 100644 index 000000000..a91cc26d0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java @@ -0,0 +1,18 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class SelectedOptionItemCountOutOfRangeException extends BadRequestException { + + public SelectedOptionItemCountOutOfRangeException(long questionId, int selectedCount, + int minSelectionCount, int maxSelectionCount) { + super("체크박스 응답 개수가 범위를 벗어났어요. (선택된 개수: %d, 최소 개수: %d, 최대 개수: %d)" + .formatted(selectedCount, minSelectionCount, maxSelectionCount)); + log.warn( + "CheckBox answer count out of range - questionId: {}, selectedCount: {}, minSelectionCount: {}, maxSelectionCount: {}", + questionId, selectedCount, minSelectionCount, maxSelectionCount, this + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java new file mode 100644 index 000000000..d2a981e12 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java @@ -0,0 +1,18 @@ +package reviewme.review.service.exception; + +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +public class SubmittedQuestionAndProvidedQuestionMismatchException extends UnexpectedRequestException { + + public SubmittedQuestionAndProvidedQuestionMismatchException(Collection submittedQuestionIds, + Collection providedQuestionIds) { + super("제출된 응답이 제공된 질문과 매칭되지 않아요."); + log.warn( + "Submitted questions and provided questions mismatch. submittedQuestionIds: {}, providedQuestionIds: {}", + submittedQuestionIds, providedQuestionIds, this + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java new file mode 100644 index 000000000..dc326ac32 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class SubmittedQuestionNotFoundException extends NotFoundException { + + public SubmittedQuestionNotFoundException(long questionId) { + super("제출된 질문이 존재하지 않아요."); + log.warn("Submitted question not found - questionId: {}", questionId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java b/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java new file mode 100644 index 000000000..681af19a9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class TextAnswerIncludedOptionItemException extends BadRequestException { + + public TextAnswerIncludedOptionItemException() { + super("텍스트형 응답은 옵션 항목을 포함할 수 없어요."); + log.warn("Text type answer cannot have option items", this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java b/backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java new file mode 100644 index 000000000..2afeaf0b0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java @@ -0,0 +1,14 @@ +package reviewme.review.service.exception; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class UnnecessaryQuestionIncludedException extends BadRequestException { + + public UnnecessaryQuestionIncludedException(List unnecessaryQuestionIds) { + super("제출해야 할 질문 이외의 질문에 응답했습니다."); + log.warn("Unnecessary question has submitted. unnecessaryQuestionIds: {}", unnecessaryQuestionIds, this); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java new file mode 100644 index 000000000..4bf631b98 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java @@ -0,0 +1,47 @@ +package reviewme.reviewgroup.controller; + +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.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reviewme.reviewgroup.service.ReviewGroupLookupService; +import reviewme.reviewgroup.service.ReviewGroupService; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; + +@RestController +@RequiredArgsConstructor +public class ReviewGroupController { + + private final ReviewGroupService reviewGroupService; + private final ReviewGroupLookupService reviewGroupLookupService; + + @GetMapping("/v2/groups") + public ResponseEntity getReviewGroupSummary(@RequestParam String reviewRequestCode) { + ReviewGroupResponse response = reviewGroupLookupService.getReviewGroupSummary(reviewRequestCode); + return ResponseEntity.ok(response); + } + + @PostMapping("/v2/groups") + public ResponseEntity createReviewGroup( + @Valid @RequestBody ReviewGroupCreationRequest request + ) { + ReviewGroupCreationResponse response = reviewGroupService.createReviewGroup(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/v2/groups/check") + public ResponseEntity checkGroupAccessCode( + @RequestBody @Valid CheckValidAccessRequest request + ) { + CheckValidAccessResponse response = reviewGroupService.checkGroupAccessCode(request); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java b/backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java new file mode 100644 index 000000000..25764e63d --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java @@ -0,0 +1,38 @@ +package reviewme.reviewgroup.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.reviewgroup.domain.exception.InvalidGroupAccessCodeFormatException; +import reviewme.util.Encoder; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class GroupAccessCode { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]{4,20}$"); + + @Column(name = "group_access_code", nullable = false) + private String code; + + public GroupAccessCode(String code) { + validateGroupAccessCode(code); + this.code = Encoder.encode(code); + } + + private void validateGroupAccessCode(String groupAccessCode) { + Matcher matcher = PATTERN.matcher(groupAccessCode); + if (!matcher.matches()) { + throw new InvalidGroupAccessCodeFormatException(groupAccessCode); + } + } + + public boolean matches(String groupAccessCode) { + return code.equals(Encoder.encode(groupAccessCode)); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java new file mode 100644 index 000000000..9da094186 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java @@ -0,0 +1,76 @@ +package reviewme.reviewgroup.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.review.domain.exception.InvalidProjectNameLengthException; +import reviewme.review.domain.exception.InvalidRevieweeNameLengthException; + +@Entity +@Table(name = "review_group") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ReviewGroup { + + private static final int MIN_REVIEWEE_LENGTH = 1; + private static final int MAX_REVIEWEE_LENGTH = 50; + private static final int MIN_PROJECT_NAME_LENGTH = 1; + private static final int MAX_PROJECT_NAME_LENGTH = 50; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reviewee", nullable = false) + private String reviewee; + + @Column(name = "project_name", nullable = false) + private String projectName; + + @Column(name = "review_request_code", nullable = false) + private String reviewRequestCode; + + @Embedded + private GroupAccessCode groupAccessCode; + + @Column(name = "template_id", nullable = false) + private long templateId = 1L; + + public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode) { + validateRevieweeLength(reviewee); + validateProjectNameLength(projectName); + this.reviewee = reviewee; + this.projectName = projectName; + this.reviewRequestCode = reviewRequestCode; + this.groupAccessCode = new GroupAccessCode(groupAccessCode); + } + + private void validateRevieweeLength(String reviewee) { + if (reviewee.length() < MIN_REVIEWEE_LENGTH || reviewee.length() > MAX_REVIEWEE_LENGTH) { + throw new InvalidRevieweeNameLengthException(reviewee.length(), MIN_REVIEWEE_LENGTH, MAX_REVIEWEE_LENGTH); + } + } + + private void validateProjectNameLength(String projectName) { + if (projectName.length() < MIN_PROJECT_NAME_LENGTH || projectName.length() > MAX_PROJECT_NAME_LENGTH) { + throw new InvalidProjectNameLengthException( + projectName.length(), MIN_PROJECT_NAME_LENGTH, MAX_PROJECT_NAME_LENGTH + ); + } + } + + public boolean matchesGroupAccessCode(String code) { + return groupAccessCode.matches(code); + } + + public String getGroupAccessCode() { + return groupAccessCode.getCode(); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java new file mode 100644 index 000000000..e4425871b --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java @@ -0,0 +1,13 @@ +package reviewme.reviewgroup.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidGroupAccessCodeFormatException extends BadRequestException { + + public InvalidGroupAccessCodeFormatException(String groupAccessCode) { + super("그룹 액세스 코드 형식이 올바르지 않아요."); + log.warn("Invalid groupAccessCode format - groupAccessCode: {}", groupAccessCode); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java b/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java new file mode 100644 index 000000000..5dd2d3ed8 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java @@ -0,0 +1,18 @@ +package reviewme.reviewgroup.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.reviewgroup.domain.ReviewGroup; + +@Repository +public interface ReviewGroupRepository extends JpaRepository { + + Optional findByReviewRequestCode(String reviewRequestCode); + + Optional findByReviewRequestCodeAndGroupAccessCode_Code( + String reviewRequestCode, String groupAccessCode + ); + + boolean existsByReviewRequestCode(String reviewRequestCode); +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java b/backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java new file mode 100644 index 000000000..852140f2c --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java @@ -0,0 +1,23 @@ +package reviewme.reviewgroup.service; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Component; + +@Component +public class RandomCodeGenerator { + + private static final Random random = ThreadLocalRandom.current(); + private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + private static final String NUMBERS = "0123456789"; + private static final String CHARACTER = UPPERCASE + LOWERCASE + NUMBERS; + + public String generate(int length) { + StringBuilder sb = new StringBuilder(); + random.ints(length, 0, CHARACTER.length()) + .mapToObj(CHARACTER::charAt) + .forEach(sb::append); + return sb.toString(); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java new file mode 100644 index 000000000..324d56817 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java @@ -0,0 +1,22 @@ +package reviewme.reviewgroup.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; + +@Service +@RequiredArgsConstructor +public class ReviewGroupLookupService { + + private final ReviewGroupRepository reviewGroupRepository; + + public ReviewGroupResponse getReviewGroupSummary(String reviewRequestCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + return new ReviewGroupResponse(reviewGroup.getReviewee(), reviewGroup.getProjectName()); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java new file mode 100644 index 000000000..fa197a3e7 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java @@ -0,0 +1,46 @@ +package reviewme.reviewgroup.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; + +@Service +@RequiredArgsConstructor +public class ReviewGroupService { + + private static final int REVIEW_REQUEST_CODE_LENGTH = 8; + + private final ReviewGroupRepository reviewGroupRepository; + private final RandomCodeGenerator randomCodeGenerator; + + @Transactional + public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest request) { + String reviewRequestCode; + do { + reviewRequestCode = randomCodeGenerator.generate(REVIEW_REQUEST_CODE_LENGTH); + } while (reviewGroupRepository.existsByReviewRequestCode(reviewRequestCode)); + + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup( + request.revieweeName(), request.projectName(), reviewRequestCode, request.groupAccessCode() + ) + ); + return new ReviewGroupCreationResponse(reviewGroup.getReviewRequestCode()); + } + + @Transactional(readOnly = true) + public CheckValidAccessResponse checkGroupAccessCode(CheckValidAccessRequest request) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(request.reviewRequestCode()) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(request.reviewRequestCode())); + + boolean hasAccess = reviewGroup.matchesGroupAccessCode(request.groupAccessCode()); + return new CheckValidAccessResponse(hasAccess); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java new file mode 100644 index 000000000..8a55df064 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java @@ -0,0 +1,13 @@ +package reviewme.reviewgroup.service.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CheckValidAccessRequest( + + @NotBlank(message = "리뷰 요청 코드를 입력하세요.") + String reviewRequestCode, + + @NotBlank(message = "리뷰 확인 코드를 입력하세요.") + String groupAccessCode +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java new file mode 100644 index 000000000..01444a880 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java @@ -0,0 +1,6 @@ +package reviewme.reviewgroup.service.dto; + +public record CheckValidAccessResponse( + boolean hasAccess +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java new file mode 100644 index 000000000..fdfe49dc5 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java @@ -0,0 +1,16 @@ +package reviewme.reviewgroup.service.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ReviewGroupCreationRequest( + + @NotBlank(message = "리뷰이 이름을 입력해주세요.") + String revieweeName, + + @NotBlank(message = "프로젝트 이름을 입력해주세요.") + String projectName, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String groupAccessCode +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java new file mode 100644 index 000000000..d1c61dcf3 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java @@ -0,0 +1,6 @@ +package reviewme.reviewgroup.service.dto; + +public record ReviewGroupCreationResponse( + String reviewRequestCode +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java new file mode 100644 index 000000000..ea6f12a29 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java @@ -0,0 +1,8 @@ +package reviewme.reviewgroup.service.dto; + +public record ReviewGroupResponse( + + String revieweeName, + String projectName +) { +} diff --git a/backend/src/main/java/reviewme/template/controller/TemplateController.java b/backend/src/main/java/reviewme/template/controller/TemplateController.java new file mode 100644 index 000000000..f09650656 --- /dev/null +++ b/backend/src/main/java/reviewme/template/controller/TemplateController.java @@ -0,0 +1,22 @@ +package reviewme.template.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reviewme.template.service.TemplateService; +import reviewme.template.service.dto.response.TemplateResponse; + +@RestController +@RequiredArgsConstructor +public class TemplateController { + + private final TemplateService templateService; + + @GetMapping("/v2/reviews/write") + public ResponseEntity getReviewForm(@RequestParam String reviewRequestCode) { + TemplateResponse response = templateService.generateReviewForm(reviewRequestCode); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/Section.java b/backend/src/main/java/reviewme/template/domain/Section.java new file mode 100644 index 000000000..696f39bc6 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/Section.java @@ -0,0 +1,72 @@ +package reviewme.template.domain; + +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.OneToMany; +import jakarta.persistence.Table; +import java.util.Collection; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "section") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Section { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "visible_type", nullable = false) + @Enumerated(EnumType.STRING) + private VisibleType visibleType; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "section_id", nullable = false, updatable = false) + private List questionIds; + + @Column(name = "on_selected_option_id", nullable = true) + private Long onSelectedOptionId; + + @Column(name = "section_name", nullable = false) + private String sectionName; + + @Column(name = "header", nullable = false, length = 1_000) + private String header; + + @Column(name = "position", nullable = false) + private int position; + + public Section(VisibleType visibleType, List questionIds, + Long onSelectedOptionId, String sectionName, String header, int position) { + this.visibleType = visibleType; + this.questionIds = questionIds.stream() + .map(SectionQuestion::new) + .toList(); + this.onSelectedOptionId = onSelectedOptionId; + this.sectionName = sectionName; + this.header = header; + this.position = position; + } + + public boolean isVisibleBySelectedOptionIds(Collection selectedOptionIds) { + return visibleType == VisibleType.ALWAYS || selectedOptionIds.contains(onSelectedOptionId); + } + + public String convertHeader(String target, String replacement) { + return header.replace(target, replacement); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/SectionQuestion.java b/backend/src/main/java/reviewme/template/domain/SectionQuestion.java new file mode 100644 index 000000000..eaac6e73e --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/SectionQuestion.java @@ -0,0 +1,32 @@ +package reviewme.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "section_question") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class SectionQuestion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "section_id", nullable = false, insertable = false, updatable = false) + private long sectionId; + + @Column(name = "question_id", nullable = false) + private long questionId; + + public SectionQuestion(long questionId) { + this.questionId = questionId; + } +} diff --git a/backend/src/main/java/reviewme/template/domain/Template.java b/backend/src/main/java/reviewme/template/domain/Template.java new file mode 100644 index 000000000..29b6f36d7 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/Template.java @@ -0,0 +1,38 @@ +package reviewme.template.domain; + +import jakarta.persistence.CascadeType; +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.OneToMany; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "template") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Template { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "template_id", nullable = false, updatable = false) + private List sectionIds; + + public Template(List sectionIds) { + this.sectionIds = sectionIds.stream() + .map(TemplateSection::new) + .toList(); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/TemplateSection.java b/backend/src/main/java/reviewme/template/domain/TemplateSection.java new file mode 100644 index 000000000..6d451ee80 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/TemplateSection.java @@ -0,0 +1,32 @@ +package reviewme.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "template_section") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class TemplateSection { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_id", nullable = false, insertable = false, updatable = false) + private long templateId; + + @Column(name = "section_id", nullable = false) + private long sectionId; + + public TemplateSection(long sectionId) { + this.sectionId = sectionId; + } +} diff --git a/backend/src/main/java/reviewme/template/domain/VisibleType.java b/backend/src/main/java/reviewme/template/domain/VisibleType.java new file mode 100644 index 000000000..3f899f526 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/VisibleType.java @@ -0,0 +1,8 @@ +package reviewme.template.domain; + +public enum VisibleType { + + ALWAYS, + CONDITIONAL, + ; +} diff --git a/backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java b/backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java new file mode 100644 index 000000000..88bcd7f3b --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java @@ -0,0 +1,13 @@ +package reviewme.template.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class OptionGroupNotFoundByQuestionIdException extends DataInconsistencyException { + + public OptionGroupNotFoundByQuestionIdException(long questionId) { + super("응답한 질문과 대응하는 선택형 문항이 존재하지 않아요."); + log.error("User submitted checkBoxAnswer without provided options - questionId: {}", questionId); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java b/backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java new file mode 100644 index 000000000..35d03ab73 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.template.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class SectionInTemplateNotFoundException extends DataInconsistencyException { + + public SectionInTemplateNotFoundException(long templateId, long sectionId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.warn("SectionNotFoundException has occurred - templateId: {}, sectionId: {}", templateId, sectionId); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java b/backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java new file mode 100644 index 000000000..8380dc304 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java @@ -0,0 +1,14 @@ +package reviewme.template.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class TemplateNotFoundByReviewGroupException extends DataInconsistencyException { + + public TemplateNotFoundByReviewGroupException(long reviewGroupId, long templateId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Template not found by groupAccessCode - reviewGroupId: {}, templateId: {}", + reviewGroupId, templateId, this); + } +} diff --git a/backend/src/main/java/reviewme/template/repository/SectionRepository.java b/backend/src/main/java/reviewme/template/repository/SectionRepository.java new file mode 100644 index 000000000..2b1babc98 --- /dev/null +++ b/backend/src/main/java/reviewme/template/repository/SectionRepository.java @@ -0,0 +1,20 @@ +package reviewme.template.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.template.domain.Section; + +@Repository +public interface SectionRepository extends JpaRepository { + + @Query(value = """ + SELECT s.* FROM section s + LEFT JOIN template_section ts + ON ts.section_id = s.id + WHERE ts.template_id = :templateId + ORDER BY s.position ASC + """, nativeQuery = true) + List
findAllByTemplateId(long templateId); +} diff --git a/backend/src/main/java/reviewme/template/repository/TemplateRepository.java b/backend/src/main/java/reviewme/template/repository/TemplateRepository.java new file mode 100644 index 000000000..e336a4a09 --- /dev/null +++ b/backend/src/main/java/reviewme/template/repository/TemplateRepository.java @@ -0,0 +1,9 @@ +package reviewme.template.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.template.domain.Template; + +@Repository +public interface TemplateRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/template/service/TemplateMapper.java b/backend/src/main/java/reviewme/template/service/TemplateMapper.java new file mode 100644 index 000000000..15b5cb0a2 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/TemplateMapper.java @@ -0,0 +1,111 @@ +package reviewme.template.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.domain.exception.MissingOptionItemsInOptionGroupException; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.template.domain.Section; +import reviewme.template.domain.SectionQuestion; +import reviewme.template.domain.Template; +import reviewme.template.domain.TemplateSection; +import reviewme.template.domain.exception.SectionInTemplateNotFoundException; +import reviewme.template.repository.SectionRepository; +import reviewme.template.service.dto.response.OptionGroupResponse; +import reviewme.template.service.dto.response.OptionItemResponse; +import reviewme.template.service.dto.response.QuestionResponse; +import reviewme.template.service.dto.response.SectionResponse; +import reviewme.template.service.dto.response.TemplateResponse; +import reviewme.template.service.exception.QuestionInSectionNotFoundException; + +@Component +@RequiredArgsConstructor +public class TemplateMapper { + + private final SectionRepository sectionRepository; + private final QuestionRepository questionRepository; + private final OptionGroupRepository optionGroupRepository; + private final OptionItemRepository optionItemRepository; + + public TemplateResponse mapToTemplateResponse(ReviewGroup reviewGroup, Template template) { + List sectionResponses = template.getSectionIds() + .stream() + .map(templateSection -> mapToSectionResponse(templateSection, reviewGroup)) + .toList(); + + return new TemplateResponse( + template.getId(), + reviewGroup.getReviewee(), + reviewGroup.getProjectName(), + sectionResponses + ); + } + + private SectionResponse mapToSectionResponse(TemplateSection templateSection, ReviewGroup reviewGroup) { + Section section = sectionRepository.findById(templateSection.getSectionId()) + .orElseThrow(() -> new SectionInTemplateNotFoundException( + templateSection.getTemplateId(), templateSection.getSectionId()) + ); + List questionResponses = section.getQuestionIds() + .stream() + .map(sectionQuestion -> mapToQuestionResponse(sectionQuestion, reviewGroup)) + .toList(); + + return new SectionResponse( + section.getId(), + section.getSectionName(), + section.getVisibleType().name(), + section.getOnSelectedOptionId(), + section.convertHeader("{revieweeName}", reviewGroup.getReviewee()), + questionResponses + ); + } + + private QuestionResponse mapToQuestionResponse(SectionQuestion sectionQuestion, ReviewGroup reviewGroup) { + Question question = questionRepository.findById(sectionQuestion.getQuestionId()) + .orElseThrow(() -> new QuestionInSectionNotFoundException( + sectionQuestion.getSectionId(), sectionQuestion.getQuestionId()) + ); + OptionGroupResponse optionGroupResponse = optionGroupRepository.findByQuestionId(question.getId()) + .map(this::mapToOptionGroupResponse) + .orElse(null); + + return new QuestionResponse( + question.getId(), + question.isRequired(), + question.convertContent("{revieweeName}", reviewGroup.getReviewee()), + question.getQuestionType().name(), + optionGroupResponse, + question.hasGuideline(), + question.convertGuideLine("{revieweeName}", reviewGroup.getReviewee()) + ); + } + + private OptionGroupResponse mapToOptionGroupResponse(OptionGroup optionGroup) { + List optionItems = optionItemRepository.findAllByOptionGroupId(optionGroup.getId()); + if (optionItems.isEmpty()) { + throw new MissingOptionItemsInOptionGroupException(optionGroup.getId()); + } + + List optionItemResponses = optionItems.stream() + .map(this::mapToOptionItemResponse) + .toList(); + + return new OptionGroupResponse( + optionGroup.getId(), + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount(), + optionItemResponses + ); + } + + private OptionItemResponse mapToOptionItemResponse(OptionItem optionItem) { + return new OptionItemResponse(optionItem.getId(), optionItem.getContent()); + } +} diff --git a/backend/src/main/java/reviewme/template/service/TemplateService.java b/backend/src/main/java/reviewme/template/service/TemplateService.java new file mode 100644 index 000000000..905de7ce9 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/TemplateService.java @@ -0,0 +1,34 @@ +package reviewme.template.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Template; +import reviewme.template.domain.exception.TemplateNotFoundByReviewGroupException; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.TemplateResponse; + +@Service +@RequiredArgsConstructor +public class TemplateService { + + private final ReviewGroupRepository reviewGroupRepository; + private final TemplateRepository templateRepository; + private final TemplateMapper templateMapper; + + @Transactional(readOnly = true) + public TemplateResponse generateReviewForm(String reviewRequestCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId() + )); + + return templateMapper.mapToTemplateResponse(reviewGroup, template); + } +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java new file mode 100644 index 000000000..c46f38148 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java @@ -0,0 +1,11 @@ +package reviewme.template.service.dto.response; + +import java.util.List; + +public record OptionGroupResponse( + long optionGroupId, + int minCount, + int maxCount, + List options +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java new file mode 100644 index 000000000..b9e456989 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java @@ -0,0 +1,7 @@ +package reviewme.template.service.dto.response; + +public record OptionItemResponse( + long optionId, + String content +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java new file mode 100644 index 000000000..90d1fb45e --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java @@ -0,0 +1,14 @@ +package reviewme.template.service.dto.response; + +import jakarta.annotation.Nullable; + +public record QuestionResponse( + long questionId, + boolean required, + String content, + String questionType, + @Nullable OptionGroupResponse optionGroup, + boolean hasGuideline, + @Nullable String guideline +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java new file mode 100644 index 000000000..31ae9d849 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java @@ -0,0 +1,14 @@ +package reviewme.template.service.dto.response; + +import jakarta.annotation.Nullable; +import java.util.List; + +public record SectionResponse( + long sectionId, + String sectionName, + String visible, + @Nullable Long onSelectedOptionId, + String header, + List questions +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java new file mode 100644 index 000000000..35575ca26 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java @@ -0,0 +1,11 @@ +package reviewme.template.service.dto.response; + +import java.util.List; + +public record TemplateResponse( + long formId, + String revieweeName, + String projectName, + List sections +) { +} diff --git a/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java b/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java new file mode 100644 index 000000000..23b7130e3 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.template.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class QuestionInSectionNotFoundException extends DataInconsistencyException { + + public QuestionInSectionNotFoundException(long sectionId, long questionId) { + super("섹션에 질문이 존재하지 않아요."); + log.error("Question in section not found - sectionId: {}, questionId: {}", sectionId, questionId); + } +} diff --git a/backend/src/main/java/reviewme/util/Encoder.java b/backend/src/main/java/reviewme/util/Encoder.java new file mode 100644 index 000000000..4b096d196 --- /dev/null +++ b/backend/src/main/java/reviewme/util/Encoder.java @@ -0,0 +1,32 @@ +package reviewme.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Encoder { + + private static final String SHA_256 = "SHA-256"; + + private Encoder() { + } + + public static String encode(String code) { + try { + MessageDigest messageDigest = MessageDigest.getInstance(SHA_256); + byte[] digest = messageDigest.digest(code.getBytes(UTF_8)); + return formatHexadecimal(digest); + } catch (NoSuchAlgorithmException e) { + throw new EncoderAlgorithmInitializationException(SHA_256); + } + } + + private static String formatHexadecimal(byte[] bytes) { + StringBuilder builder = new StringBuilder(); + for (byte b : bytes) { + builder.append("%02x".formatted(b)); + } + return builder.toString(); + } +} diff --git a/backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java b/backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java new file mode 100644 index 000000000..2155c6618 --- /dev/null +++ b/backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java @@ -0,0 +1,13 @@ +package reviewme.util; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.ReviewMeException; + +@Slf4j +public class EncoderAlgorithmInitializationException extends ReviewMeException { + + public EncoderAlgorithmInitializationException(String algorithm) { + super("서버 내부에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); + log.error("Failed to initialize encoder: Algorithm not found: {}", algorithm, this); + } +} diff --git a/backend/src/main/resources/api-docs.yml b/backend/src/main/resources/api-docs.yml new file mode 100644 index 000000000..d267ece30 --- /dev/null +++ b/backend/src/main/resources/api-docs.yml @@ -0,0 +1,11 @@ +docs: + info: + title: "리뷰미 API" + description: "이 문서는 리뷰미 API 구현 방법을 소개합니다." + version: "0.0.1" + +springdoc: + swagger-ui: + path: /api-docs + operations-sorter: alpha + tags-sorter: alpha diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 000000000..3cc43c9a7 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + profiles: + active: local + + config: + import: + - classpath:api-docs.yml + - classpath:logback.yml + + datasource: + url: jdbc:h2:mem:test + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + hibernate: + ddl-auto: update diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..3b744db47 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,33 @@ + + + + + + + + + UTF-8 + + ${CONSOLE_LOG_PATTERN} + + + + + UTF-8 + + ${FILE_LOG_PATTERN} + utf8 + + ${LOG_PATH}/${LOG_FILE} + + ${LOG_PATH}/${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN} + ${LOGBACK_ROLLINGPOLICY_MAX_HISTORY} + + + + + + + + + diff --git a/backend/src/main/resources/logback.yml b/backend/src/main/resources/logback.yml new file mode 100644 index 000000000..c1f811eff --- /dev/null +++ b/backend/src/main/resources/logback.yml @@ -0,0 +1,14 @@ +logging: + config: classpath:logback-spring.xml + file: + path: logs + name: review-me.log + level: + springframework: DEBUG + logback: + rolling-policy: + max-history: 100 + file-name-pattern: review-me.%d{yyyy-MM-dd}.log + pattern: + console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:- } [%15.15t] %-40.40logger{39} : %m%n%wEx" diff --git a/backend/src/main/resources/secret b/backend/src/main/resources/secret new file mode 160000 index 000000000..9e15707bb --- /dev/null +++ b/backend/src/main/resources/secret @@ -0,0 +1 @@ +Subproject commit 9e15707bb4d91435d6c460b09343d2fb6e819fe1 diff --git a/backend/src/test/java/reviewme/ReviewMeApplicationTests.java b/backend/src/test/java/reviewme/ReviewMeApplicationTests.java new file mode 100644 index 000000000..5c3210417 --- /dev/null +++ b/backend/src/test/java/reviewme/ReviewMeApplicationTests.java @@ -0,0 +1,12 @@ +package reviewme; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ReviewMeApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java new file mode 100644 index 000000000..a0bf49b5d --- /dev/null +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -0,0 +1,94 @@ +package reviewme.api; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.MockMvcOperationPreprocessorsConfigurer; +import org.springframework.restdocs.operation.preprocess.HeadersModifyingOperationPreprocessor; +import org.springframework.restdocs.operation.preprocess.UriModifyingOperationPreprocessor; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import reviewme.review.controller.ReviewController; +import reviewme.review.service.CreateReviewService; +import reviewme.review.service.ReviewDetailLookupService; +import reviewme.review.service.ReviewService; +import reviewme.reviewgroup.controller.ReviewGroupController; +import reviewme.reviewgroup.service.ReviewGroupLookupService; +import reviewme.reviewgroup.service.ReviewGroupService; +import reviewme.template.controller.TemplateController; +import reviewme.template.service.TemplateService; + +@WebMvcTest({ + ReviewGroupController.class, + ReviewController.class, + TemplateController.class +}) +@ExtendWith(RestDocumentationExtension.class) +public abstract class ApiTest { + + private MockMvcRequestSpecification spec; + + @MockBean + protected ReviewService reviewService; + + @MockBean + protected ReviewGroupService reviewGroupService; + + @MockBean + protected TemplateService templateService; + + @MockBean + protected CreateReviewService createReviewService; + + @MockBean + protected ReviewDetailLookupService reviewDetailLookupService; + + @MockBean + protected ReviewGroupLookupService reviewGroupLookupService; + + @BeforeEach + void setUpRestDocs(WebApplicationContext context, RestDocumentationContextProvider provider) { + UriModifyingOperationPreprocessor uriModifier = modifyUris() + .scheme("https") + .host("api.review-me.page") + .removePort(); + HeadersModifyingOperationPreprocessor requestHeaderModifier = modifyHeaders() + .set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .remove(HttpHeaders.CONTENT_LENGTH); + HeadersModifyingOperationPreprocessor responseHeaderModifier = modifyHeaders() + .remove(HttpHeaders.CONTENT_LENGTH) + .remove(HttpHeaders.CONNECTION) + .remove(HttpHeaders.TRANSFER_ENCODING) + .remove(HttpHeaders.VARY) + .remove("Keep-Alive"); + + MockMvcOperationPreprocessorsConfigurer configurer = documentationConfiguration(provider) + .operationPreprocessors() + .withRequestDefaults(prettyPrint(), uriModifier, requestHeaderModifier) + .withResponseDefaults(prettyPrint(), responseHeaderModifier); + + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(configurer) + .build(); + + spec = RestAssuredMockMvc.given() + .mockMvc(mockMvc); + } + + protected MockMvcRequestSpecification givenWithSpec() { + return spec.contentType(MediaType.APPLICATION_JSON_VALUE); + } +} diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java new file mode 100644 index 000000000..1d276aae1 --- /dev/null +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -0,0 +1,261 @@ +package reviewme.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.restdocs.headers.HeaderDescriptor; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.dto.response.list.ReceivedReviewCategoryResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.exception.ReviewGroupNotFoundByCodesException; + +class ReviewApiTest extends ApiTest { + + private final String request = """ + { + "reviewRequestCode": "ABCD1234", + "answers": [ + { + "questionId": 1, + "selectedOptionIds": [1, 2] + }, + { + "questionId": 2, + "text": "답변 예시 1" + } + ] + } + """; + + @Test + void 리뷰를_등록한다() { + BDDMockito.given(createReviewService.createReview(any(CreateReviewRequest.class))) + .willReturn(1L); + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + + fieldWithPath("answers[]").description("답변 목록"), + fieldWithPath("answers[].questionId").description("질문 ID"), + fieldWithPath("answers[].selectedOptionIds").description("선택한 옵션 ID 목록").optional(), + fieldWithPath("answers[].text").description("서술 답변").optional() + }; + + RestDocumentationResultHandler handler = document( + "create-review", + requestFields(requestFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(201); + } + + @Test + void 리뷰_그룹_코드가_올바르지_않은_경우_예외가_발생한다() { + BDDMockito.given(createReviewService.createReview(any(CreateReviewRequest.class))) + .willThrow(new ReviewGroupNotFoundByReviewRequestCodeException(anyString())); + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + + fieldWithPath("answers[]").description("답변 목록"), + fieldWithPath("answers[].questionId").description("질문 ID"), + fieldWithPath("answers[].selectedOptionIds").description("선택한 옵션 ID 목록").optional(), + fieldWithPath("answers[].text").description("서술형 답변").optional() + }; + + RestDocumentationResultHandler handler = document( + "create-review-invalid-review-request-code", + requestFields(requestFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(404); + } + + @Test + void 자신이_받은_리뷰_한_개를_조회한다() { + BDDMockito.given(reviewDetailLookupService.getReviewDetail(anyLong(), anyString(), anyString())) + .willReturn(TemplateFixture.templateAnswerResponse()); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + ParameterDescriptor[] requestPathDescriptors = { + parameterWithName("id").description("리뷰 ID") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("createdAt").description("리뷰 작성 날짜"), + fieldWithPath("formId").description("폼 ID"), + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + + fieldWithPath("sections[]").description("섹션 목록"), + fieldWithPath("sections[].sectionId").description("섹션 ID"), + fieldWithPath("sections[].header").description("섹션 제목"), + + fieldWithPath("sections[].questions[]").description("질문 목록"), + fieldWithPath("sections[].questions[].questionId").description("질문 ID"), + fieldWithPath("sections[].questions[].required").description("필수 여부"), + fieldWithPath("sections[].questions[].content").description("질문 내용"), + fieldWithPath("sections[].questions[].questionType").description("질문 타입"), + + fieldWithPath("sections[].questions[].optionGroup").description("옵션 그룹").optional(), + fieldWithPath("sections[].questions[].optionGroup.optionGroupId").description("옵션 그룹 ID"), + fieldWithPath("sections[].questions[].optionGroup.minCount").description("최소 선택 개수"), + fieldWithPath("sections[].questions[].optionGroup.maxCount").description("최대 선택 개수"), + + fieldWithPath("sections[].questions[].optionGroup.options[]").description("선택 항목 목록"), + fieldWithPath("sections[].questions[].optionGroup.options[].optionId").description("선택 항목 ID"), + fieldWithPath("sections[].questions[].optionGroup.options[].content").description("선택 항목 내용"), + fieldWithPath("sections[].questions[].optionGroup.options[].isChecked").description("선택 여부"), + fieldWithPath("sections[].questions[].answer").description("서술형 답변").optional(), + }; + + RestDocumentationResultHandler handler = document( + "review-detail", + requestHeaders(requestHeaderDescriptors), + pathParameters(requestPathDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .pathParam("id", "1") + .queryParam("reviewRequestCode", "00001234") + .header("groupAccessCode", "abc12344") + .when().get("/v2/reviews/{id}") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_단건_조회시_접근_코드가_올바르지_않은_경우_예외를_발생한다() { + long reviewId = 1L; + String reviewRequestCode = "00001234"; + String groupAccessCode = "43214321"; + BDDMockito.given(reviewDetailLookupService.getReviewDetail(reviewId, reviewRequestCode, groupAccessCode)) + .willThrow(new ReviewGroupNotFoundByCodesException(reviewRequestCode, groupAccessCode)); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + ParameterDescriptor[] requestPathDescriptors = { + parameterWithName("id").description("리뷰 ID") + }; + + RestDocumentationResultHandler handler = document( + "review-detail-invalid-group-access-code", + requestHeaders(requestHeaderDescriptors), + pathParameters(requestPathDescriptors) + ); + + givenWithSpec().log().all() + .pathParam("id", reviewId) + .queryParam("reviewRequestCode", reviewRequestCode) + .header("groupAccessCode", groupAccessCode) + .when().get("/v2/reviews/{id}") + .then().log().all() + .apply(handler) + .statusCode(400); + } + + @Test + void 자신이_받은_리뷰_목록을_조회한다() { + List receivedReviews = List.of( + new ReceivedReviewResponse(1L, LocalDate.of(2024, 8, 1), "(리뷰 미리보기 1)", + List.of(new ReceivedReviewCategoryResponse(1L, "카테고리 1"))), + new ReceivedReviewResponse(2L, LocalDate.of(2024, 8, 2), "(리뷰 미리보기 2)", + List.of(new ReceivedReviewCategoryResponse(2L, "카테고리 2"))) + ); + ReceivedReviewsResponse response = new ReceivedReviewsResponse("아루", "리뷰미", receivedReviews); + BDDMockito.given(reviewService.findReceivedReviews(anyString(), anyString())) + .willReturn(response); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + + fieldWithPath("reviews[]").description("리뷰 목록"), + fieldWithPath("reviews[].reviewId").description("리뷰 ID"), + fieldWithPath("reviews[].createdAt").description("리뷰 작성 날짜"), + fieldWithPath("reviews[].contentPreview").description("리뷰 미리보기"), + + fieldWithPath("reviews[].categories[]").description("카테고리 목록"), + fieldWithPath("reviews[].categories[].optionId").description("카테고리 ID"), + fieldWithPath("reviews[].categories[].content").description("카테고리 내용") + }; + + RestDocumentationResultHandler handler = document( + "received-reviews", + requestHeaders(requestHeaderDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "asdfasdf") + .header("groupAccessCode", "qwerqwer") + .when().get("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 자신이_받은_리뷰_조회시_접근_코드가_올바르지_않은_경우_예외를_발생한다() { + String reviewRequestCode = "43214321"; + String groupAccessCode = "00001234"; + BDDMockito.given(reviewService.findReceivedReviews(reviewRequestCode, groupAccessCode)) + .willThrow(new ReviewGroupNotFoundByCodesException(reviewRequestCode, groupAccessCode)); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + RestDocumentationResultHandler handler = document( + "received-reviews-invalid-group-access-code", + requestHeaders(requestHeaderDescriptors) + ); + + givenWithSpec().log().all() + .header("groupAccessCode", groupAccessCode) + .queryParam("reviewRequestCode", reviewRequestCode) + .when().get("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(400); + } +} diff --git a/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java b/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java new file mode 100644 index 000000000..3e3df8e2a --- /dev/null +++ b/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java @@ -0,0 +1,125 @@ +package reviewme.api; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; + +class ReviewGroupApiTest extends ApiTest { + + @Test + void 리뷰_그룹을_생성한다() { + BDDMockito.given(reviewGroupService.createReviewGroup(any(ReviewGroupCreationRequest.class))) + .willReturn(new ReviewGroupCreationResponse("ABCD1234")); + + String request = """ + { + "revieweeName": "아루", + "projectName": "리뷰미", + "groupAccessCode": "12341234" + } + """; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + fieldWithPath("groupAccessCode").description("리뷰 확인 코드(비밀번호)") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드") + }; + + RestDocumentationResultHandler handler = document( + "review-group-create", + requestFields(requestFieldDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/groups") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_요청_코드로_리뷰_그룹_정보를_반환한다() { + BDDMockito.given(reviewGroupLookupService.getReviewGroupSummary(anyString())) + .willReturn(new ReviewGroupResponse("아루", "리뷰미")); + + ParameterDescriptor[] parameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름") + }; + + RestDocumentationResultHandler handler = document( + "review-group-summary", + queryParameters(parameterDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/groups") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_그룹_코드와_액세스_코드로_일치_여부를_판단한다() { + BDDMockito.given(reviewGroupService.checkGroupAccessCode(any(CheckValidAccessRequest.class))) + .willReturn(new CheckValidAccessResponse(true)); + + String request = """ + { + "reviewRequestCode": "ABCD1234", + "groupAccessCode": "00001234" + } + """; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + fieldWithPath("groupAccessCode").description("그룹 접근 코드 (비밀번호)") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("hasAccess").description("코드 일치 여부 (비밀번호 일치)") + }; + + RestDocumentationResultHandler handler = document( + "review-group-check-access", + requestFields(requestFieldDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/groups/check") + .then().log().all() + .apply(handler) + .statusCode(200); + } +} diff --git a/backend/src/test/java/reviewme/api/TemplateApiTest.java b/backend/src/test/java/reviewme/api/TemplateApiTest.java new file mode 100644 index 000000000..557e59e8a --- /dev/null +++ b/backend/src/test/java/reviewme/api/TemplateApiTest.java @@ -0,0 +1,93 @@ +package reviewme.api; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; + +class TemplateApiTest extends ApiTest { + + @Test + void 리뷰_작성을_위한_템플릿을_반환한다() { + BDDMockito.given(templateService.generateReviewForm(anyString())) + .willReturn(TemplateFixture.templateResponse()); + + ParameterDescriptor[] requestParameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("formId").description("폼 ID"), + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + + fieldWithPath("sections[]").description("섹션 목록"), + fieldWithPath("sections[].sectionId").description("섹션 ID"), + fieldWithPath("sections[].sectionName").description("섹션 이름"), + fieldWithPath("sections[].visible").description("섹션 표시 여부 (반드시 보이거나, 조건부이거나)"), + fieldWithPath("sections[].onSelectedOptionId").description("섹션이 보이기 위한 선택 항목 ID").optional(), + fieldWithPath("sections[].header").description("섹션 제목"), + + fieldWithPath("sections[].questions[]").description("질문 목록"), + fieldWithPath("sections[].questions[].questionId").description("질문 ID"), + fieldWithPath("sections[].questions[].required").description("필수 여부"), + fieldWithPath("sections[].questions[].content").description("질문 내용"), + fieldWithPath("sections[].questions[].questionType").description("질문 타입"), + fieldWithPath("sections[].questions[].hasGuideline").description("가이드라인 존재 여부"), + fieldWithPath("sections[].questions[].guideline").description("가이드라인 내용").optional(), + + fieldWithPath("sections[].questions[].optionGroup").description("옵션 그룹").optional(), + fieldWithPath("sections[].questions[].optionGroup.optionGroupId").description("옵션 그룹 ID"), + fieldWithPath("sections[].questions[].optionGroup.minCount").description("최소 선택 개수"), + fieldWithPath("sections[].questions[].optionGroup.maxCount").description("최대 선택 개수"), + + fieldWithPath("sections[].questions[].optionGroup.options[]").description("선택 항목 목록"), + fieldWithPath("sections[].questions[].optionGroup.options[].optionId").description("선택 항목 ID"), + fieldWithPath("sections[].questions[].optionGroup.options[].content").description("선택 항목 내용"), + }; + + RestDocumentationResultHandler handler = document( + "get-review-form", + queryParameters(requestParameterDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/reviews/write") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_그룹이_존재하지_않는_경우_예외를_반환한다() { + BDDMockito.given(templateService.generateReviewForm(anyString())) + .willThrow(new ReviewGroupNotFoundByReviewRequestCodeException(anyString())); + + ParameterDescriptor[] requestParameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + RestDocumentationResultHandler handler = document( + "get-review-form-not-found", + queryParameters(requestParameterDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/reviews/write") + .then().log().all() + .apply(handler) + .statusCode(404); + } +} diff --git a/backend/src/test/java/reviewme/api/TemplateFixture.java b/backend/src/test/java/reviewme/api/TemplateFixture.java new file mode 100644 index 000000000..1cee386d0 --- /dev/null +++ b/backend/src/test/java/reviewme/api/TemplateFixture.java @@ -0,0 +1,109 @@ +package reviewme.api; + +import java.time.LocalDate; +import java.util.List; +import reviewme.question.domain.QuestionType; +import reviewme.review.service.dto.response.detail.OptionGroupAnswerResponse; +import reviewme.review.service.dto.response.detail.OptionItemAnswerResponse; +import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.template.domain.VisibleType; +import reviewme.template.service.dto.response.OptionGroupResponse; +import reviewme.template.service.dto.response.OptionItemResponse; +import reviewme.template.service.dto.response.QuestionResponse; +import reviewme.template.service.dto.response.SectionResponse; +import reviewme.template.service.dto.response.TemplateResponse; + +class TemplateFixture { + + public static TemplateResponse templateResponse() { + // Section 1 + List firstSectionOptions = List.of( + new OptionItemResponse(1, "커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)"), + new OptionItemResponse(2, "문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)"), + new OptionItemResponse(3, "시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)") + ); + List firstSectionQuestions = List.of( + new QuestionResponse( + 1, + true, + "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", + QuestionType.CHECKBOX.name(), + new OptionGroupResponse(1, 1, 2, firstSectionOptions), + false, + null + ) + ); + SectionResponse firstSection = new SectionResponse( + 1, "카테고리 선택", VisibleType.ALWAYS.name(), null, "아루와 함께 한 기억을 떠올려볼게요.", firstSectionQuestions + ); + + // Section 2 + List secondSectionOptions = List.of( + new OptionItemResponse(4, "반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요."), + new OptionItemResponse(5, "팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요."), + new OptionItemResponse(6, "팀의 분위기를 주도해요.") + ); + List secondSectionQuestions = List.of( + new QuestionResponse( + 2, + true, + "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", + QuestionType.CHECKBOX.name(), + new OptionGroupResponse(2, 1, 3, secondSectionOptions), + false, + null + ), + new QuestionResponse( + 3, + true, + "위에서 선택한 사항에 대해 조금 더 자세히 설명해주세요.", + QuestionType.TEXT.name(), + null, + true, + "상황을 자세하게 기록할수록 아루에게 도움이 돼요. 아루 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요." + ) + ); + SectionResponse secondSection = new SectionResponse( + 2, "커뮤니케이션 능력", VisibleType.ALWAYS.name(), 1L, "아루의 커뮤니케이션, 협업 능력을 평가해주세요.", secondSectionQuestions + ); + + return new TemplateResponse(1, "아루", "리뷰미", List.of(firstSection, secondSection)); + } + + public static TemplateAnswerResponse templateAnswerResponse() { + // Section 1 + List firstOptionAnswers = List.of( + new OptionItemAnswerResponse(1, "커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)", true), + new OptionItemAnswerResponse(2, "문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)", false), + new OptionItemAnswerResponse(3, "시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)", false) + ); + OptionGroupAnswerResponse firstOptionGroupAnswer = new OptionGroupAnswerResponse(1, 1, 2, firstOptionAnswers); + QuestionAnswerResponse firstQuestionAnswer = new QuestionAnswerResponse( + 1, true, QuestionType.CHECKBOX, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", firstOptionGroupAnswer, null + ); + SectionAnswerResponse firstSectionAnswer = new SectionAnswerResponse( + 1, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", List.of(firstQuestionAnswer) + ); + + // Section 2 + List secondOptionAnswers = List.of( + new OptionItemAnswerResponse(4, "반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.", true), + new OptionItemAnswerResponse(5, "팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.", false), + new OptionItemAnswerResponse(6, "팀의 분위기를 주도해요.", true) + ); + OptionGroupAnswerResponse secondOptionGroupAnswer = new OptionGroupAnswerResponse(2, 1, 3, secondOptionAnswers); + QuestionAnswerResponse secondQuestionAnswer = new QuestionAnswerResponse( + 2, true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", secondOptionGroupAnswer, + "아루는 커뮤니케이션과 협업 능력에서 인상깊었어요~" + ); + SectionAnswerResponse secondSectionAnswer = new SectionAnswerResponse( + 2, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", List.of(secondQuestionAnswer) + ); + + return new TemplateAnswerResponse( + 1, "아루", "리뷰미", LocalDate.of(2024, 8, 1), List.of(firstSectionAnswer, secondSectionAnswer) + ); + } +} diff --git a/backend/src/test/java/reviewme/config/TestConfig.java b/backend/src/test/java/reviewme/config/TestConfig.java new file mode 100644 index 000000000..f339dd641 --- /dev/null +++ b/backend/src/test/java/reviewme/config/TestConfig.java @@ -0,0 +1,14 @@ +package reviewme.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import reviewme.support.DatabaseCleaner; + +@TestConfiguration +public class TestConfig { + + @Bean + public DatabaseCleaner databaseCleaner() { + return new DatabaseCleaner(); + } +} diff --git a/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java b/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java new file mode 100644 index 000000000..fdaae95df --- /dev/null +++ b/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java @@ -0,0 +1,56 @@ +package reviewme.global; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.NativeWebRequest; +import reviewme.global.exception.MissingHeaderPropertyException; + +class HeaderPropertyArgumentResolverTest { + + private final HeaderPropertyArgumentResolver resolver = new HeaderPropertyArgumentResolver(); + private final MethodParameter parameter = mock(MethodParameter.class); + private final HeaderProperty headerProperty = mock(HeaderProperty.class); + + @BeforeEach + void setUp() { + given(parameter.hasParameterAnnotation(HeaderProperty.class)).willReturn(true); + given(parameter.getParameterAnnotation(HeaderProperty.class)).willReturn(headerProperty); + } + + @Test + void 검증값이_헤더에_존재하지_않으면_검증에_실패한다() { + // given + NativeWebRequest request = mock(NativeWebRequest.class); + given(request.getNativeRequest()).willReturn(new MockHttpServletRequest()); + given(headerProperty.headerName()).willReturn("test"); + + // when, then + assertThatThrownBy(() -> resolver.resolveArgument(parameter, null, request, null)) + .isInstanceOf(MissingHeaderPropertyException.class); + } + + @Test + void 검증값이_헤더에_존재하면_값을_반환한다() { + // given + String headerName = "test"; + String headerValue = "1234"; + NativeWebRequest request = mock(NativeWebRequest.class); + MockHttpServletRequest mockRequest = (new MockHttpServletRequest()); + mockRequest.addHeader(headerName, headerValue); + given(request.getNativeRequest()).willReturn(mockRequest); + given(headerProperty.headerName()).willReturn(headerName); + + // when + String actual = resolver.resolveArgument(parameter, null, request, null); + + // then + assertThat(actual).isEqualTo(headerValue); + } +} diff --git a/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java b/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java new file mode 100644 index 000000000..046eeeea9 --- /dev/null +++ b/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java @@ -0,0 +1,84 @@ +package reviewme.question.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.repository.ReviewRepository; + +@DataJpaTest +class OptionItemRepositoryTest { + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Test + void 리뷰_아이디로_선택한_옵션_아이템_아이디를_불러온다() { + // given + long optionId1 = optionItemRepository.save(new OptionItem("1", 0, 1, OptionType.KEYWORD)).getId(); + long optionId2 = optionItemRepository.save(new OptionItem("2", 0, 1, OptionType.KEYWORD)).getId(); + long optionId3 = optionItemRepository.save(new OptionItem("3", 0, 1, OptionType.KEYWORD)).getId(); + long optionId4 = optionItemRepository.save(new OptionItem("4", 0, 1, OptionType.KEYWORD)).getId(); + optionItemRepository.save(new OptionItem("5", 0, 1, OptionType.KEYWORD)); + + List checkboxAnswers = List.of( + new CheckboxAnswer(1, List.of(optionId1, optionId2)), + new CheckboxAnswer(2, List.of(optionId3, optionId4)) + ); + Review review = reviewRepository.save(new Review(0, 0, List.of(), checkboxAnswers)); + + // when + Set actual = optionItemRepository.findSelectedOptionItemIdsByReviewId(review.getId()); + + // then + assertThat(actual).containsExactlyInAnyOrder(optionId1, optionId2, optionId3, optionId4); + } + + @Test + void 리뷰_아이디와_질문_아이디로_선택한_옵션_아이템을_순서대로_불러온다() { + // given + Question question1 = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문1", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문2", null, 2)); + + long optionGroupId = optionGroupRepository.save(new OptionGroup(question1.getId(), 1, 3)).getId(); + long optionId1 = optionItemRepository.save(new OptionItem("1", optionGroupId, 3, OptionType.KEYWORD)).getId(); + long optionId2 = optionItemRepository.save(new OptionItem("2", optionGroupId, 2, OptionType.KEYWORD)).getId(); + long optionId3 = optionItemRepository.save(new OptionItem("3", optionGroupId, 1, OptionType.KEYWORD)).getId(); + long optionId4 = optionItemRepository.save(new OptionItem("4", optionGroupId, 1, OptionType.KEYWORD)).getId(); + long optionId5 = optionItemRepository.save(new OptionItem("5", optionGroupId, 1, OptionType.KEYWORD)).getId(); + + List checkboxAnswers = List.of( + new CheckboxAnswer(question1.getId(), List.of(optionId1, optionId3)), + new CheckboxAnswer(question2.getId(), List.of(optionId4)) + ); + + Review review = reviewRepository.save(new Review(0, 0, List.of(), checkboxAnswers)); + + // when + List actual = optionItemRepository.findSelectedOptionItemsByReviewIdAndQuestionId( + review.getId(), question1.getId() + ); + + // then + assertThat(actual).extracting(OptionItem::getId).containsExactly(optionId3, optionId1); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java b/backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java new file mode 100644 index 000000000..504b10071 --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java @@ -0,0 +1,52 @@ +package reviewme.review.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.exception.MissingCheckboxAnswerForQuestionException; + +class CheckboxAnswersTest { + + @Test + void 질문에_해당하는_답변이_없으면_예외를_발생한다() { + // given + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(List.of(new CheckboxAnswer(1, List.of(1L)))); + + // when, then + assertThatThrownBy(() -> checkboxAnswers.getAnswerByQuestionId(2)) + .isInstanceOf(MissingCheckboxAnswerForQuestionException.class); + } + + @Test + void 질문_ID로_선택형_답변을_반환한다() { + // given + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(List.of(new CheckboxAnswer(1, List.of(1L)))); + + // when + CheckboxAnswer actual = checkboxAnswers.getAnswerByQuestionId(1); + + // then + assertThat(actual.getSelectedOptionIds()) + .extracting(CheckBoxAnswerSelectedOption::getSelectedOptionId) + .containsExactly(1L); + } + + @Test + void 질문_ID에_해당하는_답변이_있는지_확인한다() { + // given + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(List.of(new CheckboxAnswer(1, List.of(1L)))); + + // when + boolean actual1 = checkboxAnswers.hasAnswerByQuestionId(1); + boolean actual2 = checkboxAnswers.hasAnswerByQuestionId(2); + + // then + assertAll( + () -> assertThat(actual1).isTrue(), + () -> assertThat(actual2).isFalse() + ); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java b/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java new file mode 100644 index 000000000..82eeb7e0b --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java @@ -0,0 +1,50 @@ +package reviewme.review.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.exception.MissingTextAnswerForQuestionException; + +class TextAnswersTest { + + @Test + void 질문에_해당하는_답변이_없으면_예외를_발생한다() { + // given + TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답".repeat(20)))); + + // when, then + assertThatThrownBy(() -> textAnswers.getAnswerByQuestionId(2)) + .isInstanceOf(MissingTextAnswerForQuestionException.class); + } + + @Test + void 질문_ID로_서술형_답변을_반환한다() { + // given + TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답".repeat(20)))); + + // when + TextAnswer actual = textAnswers.getAnswerByQuestionId(1); + + // then + assertThat(actual.getContent()).isEqualTo("답".repeat(20)); + } + + @Test + void 질문_ID에_해당하는_답변이_있는지_확인한다() { + // given + TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답변"))); + + // when + boolean actual1 = textAnswers.hasAnswerByQuestionId(1); + boolean actual2 = textAnswers.hasAnswerByQuestionId(2); + + // then + assertAll( + () -> assertThat(actual1).isTrue(), + () -> assertThat(actual2).isFalse() + ); + } +} diff --git a/backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java new file mode 100644 index 000000000..6ffc56eeb --- /dev/null +++ b/backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java @@ -0,0 +1,71 @@ +package reviewme.review.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.QuestionRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@DataJpaTest +class QuestionRepositoryTest { + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 섹션_아이디로_질문_목록을_순서대로_가져온다() { + // given + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문1", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문2", null, 2)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문3", null, 3)); + questionRepository.save(new Question(true, QuestionType.TEXT, "질문4", null, 1)); + + List questionIds = List.of(question3.getId(), question1.getId(), question2.getId()); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, questionIds, null, "sectionName", "header", 0)); + + // when + List actual = questionRepository.findAllBySectionId(section.getId()); + + // then + assertThat(actual).extracting(Question::getId) + .containsExactly(question1.getId(), question2.getId(), question3.getId()); + } + + @Test + void 템플릿_아이디로_질문_목록을_모두_가져온다() { + // given + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문1", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문2", null, 2)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문3", null, 1)); + Question question4 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문4", null, 2)); + + List sectionQuestion1 = List.of(question1.getId(), question2.getId()); + List sectionQuestion2 = List.of(question3.getId(), question4.getId()); + Section section1 = sectionRepository.save(new Section(VisibleType.ALWAYS, sectionQuestion1, null, "sectionName", "header", 0)); + sectionRepository.save(new Section(VisibleType.ALWAYS, sectionQuestion2, null, "sectionName", "header", 0)); + List sectionIds = List.of(section1.getId()); + Template template = templateRepository.save(new Template(sectionIds)); + + // when + Set actual = questionRepository.findAllQuestionIdByTemplateId(template.getId()); + + // then + assertThat(actual).containsExactlyInAnyOrder(question1.getId(), question2.getId()); + } +} diff --git a/backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java b/backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java new file mode 100644 index 000000000..f42bd2072 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java @@ -0,0 +1,160 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; +import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.support.ServiceTest; +import reviewme.template.domain.exception.OptionGroupNotFoundByQuestionIdException; + +@ServiceTest +class CreateCheckBoxAnswerRequestValidatorTest { + + @Autowired + private CreateCheckBoxAnswerRequestValidator createCheckBoxAnswerRequestValidator; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + private Question savedQuestion; + + @BeforeEach + void setUp() { + savedQuestion = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문", null, 1)); + } + + @Test + void 저장되지_않은_질문에_대한_응답이면_예외가_발생한다() { + // given + long notSavedQuestionId = 100L; + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + notSavedQuestionId, List.of(1L), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(SubmittedQuestionNotFoundException.class); + } + + @Test + void 선택형_질문에_텍스트_응답을_하면_예외가_발생한다() { + // given + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(1L), "서술형 응답" + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(CheckBoxAnswerIncludedTextException.class); + } + + @Test + void 저장되지_않은_옵션그룹에_대해_응답하면_예외가_발생한다() { + // given + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(1L), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(OptionGroupNotFoundByQuestionIdException.class); + } + + @Test + void 필수_선택형_질문에_응답을_하지_않으면_예외가_발생한다() { + // given + optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 3) + ); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), + null, + null); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(RequiredQuestionNotAnsweredException.class); + } + + @Test + void 옵션그룹에서_제공하지_않은_옵션아이템을_응답하면_예외가_발생한다() { + // given + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 3) + ); + OptionItem savedOptionItem = optionItemRepository.save( + new OptionItem("옵션", savedOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(savedOptionItem.getId() + 1L), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(CheckBoxAnswerIncludedNotProvidedOptionItemException.class); + } + + @Test + void 옵션그룹에서_정한_최소_선택_수_보다_적게_선택하면_예외가_발생한다() { + // given + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 2, 3) + ); + OptionItem savedOptionItem1 = optionItemRepository.save( + new OptionItem("옵션1", savedOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(savedOptionItem1.getId()), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(SelectedOptionItemCountOutOfRangeException.class); + } + + @Test + void 옵션그룹에서_정한_최대_선택_수_보다_많이_선택하면_예외가_발생한다() { + // given + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 1) + ); + OptionItem savedOptionItem1 = optionItemRepository.save( + new OptionItem("옵션1", savedOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + OptionItem savedOptionItem2 = optionItemRepository.save( + new OptionItem("옵션2", savedOptionGroup.getId(), 2, OptionType.KEYWORD) + ); + + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(savedOptionItem1.getId(), savedOptionItem2.getId()), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(SelectedOptionItemCountOutOfRangeException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java b/backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java new file mode 100644 index 000000000..8901b8d67 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java @@ -0,0 +1,264 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.repository.CheckboxAnswerRepository; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.repository.TextAnswerRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.exception.MissingRequiredQuestionException; +import reviewme.review.service.exception.UnnecessaryQuestionIncludedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class CreateReviewServiceTest { + + @Autowired + private CreateReviewService createReviewService; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TextAnswerRepository textAnswerRepository; + + @Autowired + private CheckboxAnswerRepository checkboxAnswerRepository; + + @Test + void 필수_질문에_모두_응답하는_경우_예외가_발생하지_않는다() { + // 리뷰 그룹 저장 + String reviewRequestCode = "1234"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + + // 필수 선택형 질문, 섹션 저장 + Question alwaysRequiredQuestion = questionRepository.save( + new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1) + ); + OptionGroup alwaysRequiredOptionGroup = optionGroupRepository.save( + new OptionGroup(alwaysRequiredQuestion.getId(), 1, 2) + ); + OptionItem alwaysRequiredOptionItem1 = optionItemRepository.save( + new OptionItem("선택지", alwaysRequiredOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + OptionItem alwaysRequiredOptionItem2 = optionItemRepository.save( + new OptionItem("선택지", alwaysRequiredOptionGroup.getId(), 2, OptionType.KEYWORD) + ); + Section alwaysRequiredSection = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(alwaysRequiredQuestion.getId()), null, "섹션명", "말머리", 1) + ); + + // 필수가 아닌 서술형 질문 저장 + Question notRequiredQuestion = questionRepository.save( + new Question(false, QuestionType.TEXT, "질문", "가이드라인", 1) + ); + Section notRequiredSection = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(notRequiredQuestion.getId()), null, "섹션명", "말머리", 1) + ); + + // optionItem 선택에 따라서 required 가 달라지는 섹션1 저장 + Question conditionalTextQuestion1 = questionRepository.save( + new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1) + ); + Question conditionalCheckQuestion = questionRepository.save( + new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1) + ); + OptionGroup conditionalOptionGroup = optionGroupRepository.save( + new OptionGroup(conditionalCheckQuestion.getId(), 1, 2) + ); + OptionItem conditionalOptionItem = optionItemRepository.save( + new OptionItem("선택지", conditionalOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + Section conditionalSection1 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, + List.of(conditionalTextQuestion1.getId(), conditionalCheckQuestion.getId()), + alwaysRequiredOptionItem1.getId(), "섹션명", "말머리", 1) + ); + + // optionItem 선택에 따라서 required 가 달라지는 섹션2 저장 + Question conditionalQuestion2 = questionRepository.save( + new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1) + ); + Section conditionalSection2 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(conditionalQuestion2.getId()), + alwaysRequiredOptionItem2.getId(), "섹션명", "말머리", 1) + ); + + // 템플릿 저장 + templateRepository.save(new Template( + List.of(alwaysRequiredSection.getId(), conditionalSection1.getId(), + conditionalSection2.getId(), notRequiredSection.getId()) + )); + + // 각 질문에 대한 답변 생성 + CreateReviewAnswerRequest alwaysRequiredAnswer = new CreateReviewAnswerRequest( + alwaysRequiredQuestion.getId(), List.of(alwaysRequiredOptionItem1.getId()), null); + CreateReviewAnswerRequest conditionalTextAnswer1 = new CreateReviewAnswerRequest( + conditionalTextQuestion1.getId(), null, "답변".repeat(30)); + CreateReviewAnswerRequest conditionalCheckAnswer1 = new CreateReviewAnswerRequest( + conditionalCheckQuestion.getId(), List.of(conditionalOptionItem.getId()), null); + CreateReviewAnswerRequest conditionalTextAnswer2 = new CreateReviewAnswerRequest( + conditionalQuestion2.getId(), null, "답변".repeat(30)); + + // 상황별로 다르게 구성한 리뷰 생성 dto + CreateReviewRequest properRequest = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalTextAnswer1, conditionalCheckAnswer1)); + CreateReviewRequest selectedOptionIdQuestionMissingRequest1 = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer)); + CreateReviewRequest selectedOptionIdQuestionMissingRequest2 = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalTextAnswer1)); + CreateReviewRequest selectedOptionIdQuestionMissingRequest3 = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalCheckAnswer1)); + CreateReviewRequest unnecessaryQuestionIncludedRequest = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalTextAnswer1, + conditionalCheckAnswer1, conditionalTextAnswer2)); + + // when, then + assertThatCode(() -> createReviewService.createReview(properRequest)) + .doesNotThrowAnyException(); + assertThatCode(() -> createReviewService.createReview(selectedOptionIdQuestionMissingRequest1)) + .isInstanceOf(MissingRequiredQuestionException.class); + assertThatCode(() -> createReviewService.createReview(selectedOptionIdQuestionMissingRequest2)) + .isInstanceOf(MissingRequiredQuestionException.class); + assertThatCode(() -> createReviewService.createReview(selectedOptionIdQuestionMissingRequest3)) + .isInstanceOf(MissingRequiredQuestionException.class); + assertThatCode(() -> createReviewService.createReview(unnecessaryQuestionIncludedRequest)) + .isInstanceOf(UnnecessaryQuestionIncludedException.class); + } + + @Test + void 텍스트가_포함된_리뷰를_저장한다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + String expectedTextAnswer = "답".repeat(20); + Question savedQuestion = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest createReviewAnswerRequest = new CreateReviewAnswerRequest(savedQuestion.getId(), null, + expectedTextAnswer); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(createReviewAnswerRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(textAnswerRepository.findAll()).hasSize(1); + } + + @Test + void 필수가_아닌_텍스트형_응답에_빈문자열이_들어오면_저장하지_않는다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + Question savedQuestion = questionRepository.save( + new Question(false, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest emptyTextReviewRequest = new CreateReviewAnswerRequest( + savedQuestion.getId(), null, ""); + CreateReviewAnswerRequest validTextReviewRequest = new CreateReviewAnswerRequest( + savedQuestion.getId(), null, "질문 1 답변 (20자 이상 입력 적용)"); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(emptyTextReviewRequest, validTextReviewRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(textAnswerRepository.findAll()).hasSize(1); + } + + @Test + void 체크박스가_포함된_리뷰를_저장한다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + Question savedQuestion = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1)); + OptionGroup savedOptionGroup = optionGroupRepository.save(new OptionGroup(savedQuestion.getId(), 2, 2)); + OptionItem savedOptionItem1 = optionItemRepository.save( + new OptionItem("선택지1", savedOptionGroup.getId(), 1, OptionType.KEYWORD)); + OptionItem savedOptionItem2 = optionItemRepository.save( + new OptionItem("선택지2", savedOptionGroup.getId(), 2, OptionType.KEYWORD)); + CreateReviewAnswerRequest createReviewAnswerRequest = new CreateReviewAnswerRequest(savedQuestion.getId(), + List.of(savedOptionItem1.getId(), savedOptionItem2.getId()), null); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(createReviewAnswerRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(checkboxAnswerRepository.findAll()).hasSize(1); + } + + @Test + void 적정_글자수인_텍스트_응답인_경우_정상_저장된다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + String expectedTextAnswer = "답".repeat(1000); + Question savedQuestion = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest createReviewAnswerRequest = new CreateReviewAnswerRequest(savedQuestion.getId(), null, + expectedTextAnswer); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(createReviewAnswerRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(textAnswerRepository.findAll()).hasSize(1); + } +} diff --git a/backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java b/backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java new file mode 100644 index 000000000..8224d4acd --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java @@ -0,0 +1,77 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.exception.InvalidTextAnswerLengthException; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; +import reviewme.support.ServiceTest; + +@ServiceTest +class CreateTextAnswerRequestValidatorTest { + + @Autowired + private CreateTextAnswerRequestValidator createTextAnswerRequestValidator; + + @Autowired + private QuestionRepository questionRepository; + + @Test + void 저장되지_않은_질문에_대한_대답이면_예외가_발생한다() { + // given + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(100L, null, "텍스트형 응답"); + + // when, then + assertThatCode(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(SubmittedQuestionNotFoundException.class); + } + + @Test + void 텍스트형_질문에_선택형_응답을_하면_예외가_발생한다() { + // given + Question savedQuestion + = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(savedQuestion.getId(), List.of(1L), "응답"); + + // when, then + assertThatCode(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(TextAnswerIncludedOptionItemException.class); + } + + @Test + void 필수_텍스트형_질문에_응답을_하지_않으면_예외가_발생한다() { + // given + Question savedQuestion + = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(savedQuestion.getId(), null, null); + + // when, then + assertThatCode(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(RequiredQuestionNotAnsweredException.class); + } + + @ParameterizedTest + @ValueSource(ints = {19, 10001}) + void 답변_길이가_유효하지_않으면_예외가_발생한다(int length) { + // given + String textAnswer = "답".repeat(length); + Question savedQuestion + = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(savedQuestion.getId(), null, textAnswer); + + // when, then + assertThatThrownBy(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(InvalidTextAnswerLengthException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java new file mode 100644 index 000000000..84b54afb2 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java @@ -0,0 +1,214 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.review.service.exception.ReviewNotFoundByIdAndGroupException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class ReviewDetailLookupServiceTest { + + @Autowired + private ReviewDetailLookupService reviewDetailLookupService; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 잘못된_리뷰_요청_코드로_리뷰를_조회할_경우_예외를_발생한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", reviewRequestCode, groupAccessCode)); + + Review review = reviewRepository.save(new Review(0, reviewGroup.getId(), List.of(), List.of())); + + // when, then + assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( + review.getId(), "wrong" + reviewRequestCode, groupAccessCode + )).isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } + + @Test + void 잘못된_그룹_액세스_코드로_리뷰를_조회할_경우_예외를_발생한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", reviewRequestCode, groupAccessCode)); + + Review review = reviewRepository.save(new Review(0, reviewGroup.getId(), List.of(), List.of())); + + // when, then + assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( + review.getId(), reviewRequestCode, "wrong" + groupAccessCode + )).isInstanceOf(ReviewGroupUnauthorizedException.class); + } + + @Test + void 리뷰_그룹에_해당하지_않는_리뷰를_조회할_경우_예외를_발생한다() { + // given + String reviewRequestCode1 = "reviewRequestCode1"; + String groupAccessCode1 = "groupAccessCode1"; + ReviewGroup reviewGroup1 = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", reviewRequestCode1, groupAccessCode1)); + ReviewGroup reviewGroup2 = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", "ABCD", "1234")); + + Review review1 = reviewRepository.save(new Review(0, reviewGroup1.getId(), List.of(), List.of())); + Review review2 = reviewRepository.save(new Review(0, reviewGroup2.getId(), List.of(), List.of())); + + // when, then + assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( + review2.getId(), reviewRequestCode1, groupAccessCode1)) + .isInstanceOf(ReviewNotFoundByIdAndGroupException.class); + } + + @Test + void 사용자가_작성한_리뷰를_확인한다() { + // given + String reviewRequestCode = "ABCD"; + String groupAccessCode = "0000"; + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup("aru", "reviewme", reviewRequestCode, groupAccessCode)); + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문", null, 1)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "체크 1 조건", "가이드라인", 1)); + OptionGroup optionGroup = optionGroupRepository.save(new OptionGroup(question2.getId(), 1, 3)); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("체크 1", optionGroup.getId(), 1, OptionType.KEYWORD)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("체크 2", optionGroup.getId(), 1, OptionType.KEYWORD)); + + Section section1 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(question1.getId(), question2.getId()), null, "1번 섹션", "말머리", 1) + ); + Section section2 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(question3.getId()), optionItem1.getId(), "2번 섹션", "말머리", 2) + ); + Template template = templateRepository.save(new Template(List.of(section1.getId(), section2.getId()))); + + List textAnswers = List.of( + new TextAnswer(1, "질문 1 답변 (20자 이상 입력 적용)"), + new TextAnswer(3, "질문 3 답변 (20자 이상 입력 적용)") + ); + List checkboxAnswers = List.of( + new CheckboxAnswer(2, List.of(optionItem1.getId(), optionItem2.getId())) + ); + Review review = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), textAnswers, checkboxAnswers) + ); + + // when + TemplateAnswerResponse reviewDetail = reviewDetailLookupService.getReviewDetail( + review.getId(), reviewRequestCode, groupAccessCode + ); + + // then + assertThat(reviewDetail.sections()).hasSize(2); + } + + @Test + void 답변이_있는_리뷰만_보여준다() { + // given + String reviewRequestCode = "ABCD"; + String groupAccessCode = "0000"; + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup("aru", "reviewme", reviewRequestCode, groupAccessCode)); + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", null, 1)); + Question question2 = questionRepository.save(new Question(false, QuestionType.CHECKBOX, "질문", null, 1)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "체크 1 조건", "가이드라인", 1)); + Question question4 = questionRepository.save(new Question(false, QuestionType.TEXT, "선택 질문", "가이드라인", 1)); + OptionGroup optionGroup = optionGroupRepository.save(new OptionGroup(question2.getId(), 1, 3)); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("체크 1", optionGroup.getId(), 1, OptionType.KEYWORD)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("체크 2", optionGroup.getId(), 1, OptionType.KEYWORD)); + + Section section1 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(question1.getId(), question2.getId()), null, "1번 섹션", "말머리", 1) + ); + Section section2 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(question3.getId()), optionItem1.getId(), "2번 섹션", "말머리", 2) + ); + Section section3 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(question4.getId()), null, "3번 섹션", "말머리", 3) + ); + + Template template = templateRepository.save( + new Template(List.of(section1.getId(), section2.getId(), section3.getId()))); + + List textAnswers = List.of( + new TextAnswer(1, "질문 1 답변"), + new TextAnswer(3, "질문 3 답변") + ); + List checkboxAnswers = new ArrayList<>(); + Review review = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), textAnswers, checkboxAnswers) + ); + + // when + TemplateAnswerResponse reviewDetail = reviewDetailLookupService.getReviewDetail( + review.getId(), reviewRequestCode, groupAccessCode + ); + + // then + List sections = reviewDetail.sections(); + + assertAll( + () -> assertThat(sections).extracting(SectionAnswerResponse::sectionId) + .containsExactly(section1.getId(), section2.getId()), + () -> assertThat(sections.get(0).questions()) + .extracting(QuestionAnswerResponse::questionId).containsExactly(question1.getId()), + () -> assertThat(sections.get(1).questions()) + .extracting(QuestionAnswerResponse::questionId).containsExactly(question3.getId()) + ); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java b/backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java new file mode 100644 index 000000000..f63c47b5f --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java @@ -0,0 +1,41 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reviewme.review.domain.TextAnswer; + +class ReviewPreviewGeneratorTest { + + @Test + void 답변_내용이_미리보기_최대_글자를_넘는_경우_미리보기_길이만큼_잘라서_반환한다() { + // given + ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); + String answer = "*".repeat(151); + TextAnswer textAnswer = new TextAnswer(1, answer); + + // when + String actual = reviewPreviewGenerator.generatePreview(List.of(textAnswer)); + + // then + assertThat(actual).hasSize(150); + } + + @ParameterizedTest + @ValueSource(ints = {149, 150}) + void 답변_내용이_미리보기_최대_글자를_넘지_않는_경우_전체_내용을_반환한다(int length) { + // given + ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); + String answer = "*".repeat(length); + TextAnswer textAnswer = new TextAnswer(1, answer); + + // when + String actual = reviewPreviewGenerator.generatePreview(List.of(textAnswer)); + + // then + assertThat(actual).hasSize(length); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java new file mode 100644 index 000000000..f5acf1634 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java @@ -0,0 +1,109 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.CheckboxAnswerRepository; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class ReviewServiceTest { + + @Autowired + ReviewService reviewService; + + @Autowired + QuestionRepository questionRepository; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Autowired + OptionItemRepository optionItemRepository; + + @Autowired + OptionGroupRepository optionGroupRepository; + + @Autowired + SectionRepository sectionRepository; + + @Autowired + TemplateRepository templateRepository; + + @Autowired + CheckboxAnswerRepository checkboxAnswerRepository; + + @Autowired + ReviewRepository reviewRepository; + + @Test + void 리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { + assertThatThrownBy(() -> reviewService.findReceivedReviews("abc", "groupAccessCode")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } + + @Test + void 그룹_액세스_코드가_일치하지_않는_경우_예외가_발생한다() { + // given + String reviewRequestCode = "code"; + String groupAccessCode = "1234"; + reviewGroupRepository.save(new ReviewGroup("커비", "리뷰미", reviewRequestCode, groupAccessCode)); + + // when, then + assertThatThrownBy(() -> reviewService.findReceivedReviews(reviewRequestCode, "5678")) + .isInstanceOf(ReviewGroupUnauthorizedException.class); + } + + @Test + void 확인_코드에_해당하는_그룹이_존재하면_리뷰_리스트를_반환한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + Question question = questionRepository.save( + new Question(true, QuestionType.CHECKBOX, "프로젝트 기간 동안, 팀원의 강점이 드러났던 순간을 선택해주세요. (1~2개)", null, 1) + ); + OptionGroup categoryOptionGroup = optionGroupRepository.save(new OptionGroup(question.getId(), 1, 2)); + OptionItem categoryOption1 = new OptionItem("커뮤니케이션 능력 ", categoryOptionGroup.getId(), 1, OptionType.CATEGORY); + OptionItem categoryOption2 = new OptionItem("시간 관리 능력", categoryOptionGroup.getId(), 2, OptionType.CATEGORY); + optionItemRepository.saveAll(List.of(categoryOption1, categoryOption2)); + + Template template = templateRepository.save(new Template(List.of())); + + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup("커비", "리뷰미", reviewRequestCode, groupAccessCode) + ); + CheckboxAnswer categoryAnswer1 = new CheckboxAnswer(question.getId(), List.of(categoryOption1.getId())); + CheckboxAnswer categoryAnswer2 = new CheckboxAnswer(question.getId(), List.of(categoryOption2.getId())); + Review review1 = new Review(template.getId(), reviewGroup.getId(), List.of(), List.of(categoryAnswer1)); + Review review = new Review(template.getId(), reviewGroup.getId(), List.of(), List.of(categoryAnswer2)); + reviewRepository.saveAll(List.of(review1, review)); + + // when + ReceivedReviewsResponse response = reviewService.findReceivedReviews(reviewRequestCode, groupAccessCode); + + // then + assertThat(response.reviews()).hasSize(2); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java b/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java new file mode 100644 index 000000000..d6ac6055a --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java @@ -0,0 +1,63 @@ +package reviewme.reviewgroup; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.Test; +import reviewme.global.exception.BadRequestException; +import reviewme.reviewgroup.domain.ReviewGroup; + +class ReviewGroupTest { + + @Test + void 정상_생성된다() { + // given + int maxLength = 50; + int minLength = 1; + String minLengthName = "*".repeat(minLength); + String maxLengthName = "*".repeat(maxLength); + + // when, then + assertAll( + () -> assertThatCode(() -> new ReviewGroup(minLengthName, "project", "reviewCode", "groupCode")) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> new ReviewGroup(maxLengthName, "project", "reviewCode", "groupCode")) + .doesNotThrowAnyException() + ); + } + + @Test + void 리뷰이_이름이_정해진_길이에_맞지_않으면_예외가_발생한다() { + // given + int maxLength = 50; + int minLength = 1; + String insufficientName = "*".repeat(minLength - 1); + String exceedName = "*".repeat(maxLength + 1); + + // when, then + assertAll( + () -> assertThatCode(() -> new ReviewGroup(insufficientName, "project", "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class), + () -> assertThatThrownBy(() -> new ReviewGroup(exceedName, "project", "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class) + ); + } + + @Test + void 프로젝트_이름이_정해진_길이에_맞지_않으면_예외가_발생한다() { + // given + int maxLength = 50; + int minLength = 1; + String insufficientName = "*".repeat(minLength - 1); + String exceedName = "*".repeat(maxLength + 1); + + // when, then + assertAll( + () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", insufficientName, "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class), + () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", exceedName, "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class) + ); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java b/backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java new file mode 100644 index 000000000..fc5b8ec51 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java @@ -0,0 +1,35 @@ +package reviewme.reviewgroup.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reviewme.reviewgroup.domain.exception.InvalidGroupAccessCodeFormatException; + +class GroupAccessCodeTest { + + @Test + void 코드_일치_여부를_판단한다() { + // given + String code = "hello"; + GroupAccessCode groupAccessCode = new GroupAccessCode(code); + + // when, then + assertThat(groupAccessCode.matches("hello")).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"AZaz", "a0Z9", "aZ09", "ABCD123a", "1234"}) + void 정규식에_일치하면_성공적으로_생성된다(String code) { + assertDoesNotThrow(() -> new GroupAccessCode(code)); + } + + @ParameterizedTest + @ValueSource(strings = {"", "123", "123456789012345678901", "aaaa-"}) + void 정규식에_일치하지_않으면_예외가_발생한다(String code) { + assertThrows(InvalidGroupAccessCodeFormatException.class, () -> new GroupAccessCode(code)); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java b/backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java new file mode 100644 index 000000000..aee9a3b27 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java @@ -0,0 +1,21 @@ +package reviewme.reviewgroup.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class RandomCodeGeneratorTest { + + @Test + void 주어진_길이에_맞는_랜덤한_문자열을_생성한다() { + // given + int length = 8; + RandomCodeGenerator randomCodeGenerator = new RandomCodeGenerator(); + + // when + String actual = randomCodeGenerator.generate(length); + + // then + assertThat(actual).matches("[a-zA-Z0-9]{%d}".formatted(length)); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java new file mode 100644 index 000000000..6764e8586 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java @@ -0,0 +1,52 @@ +package reviewme.reviewgroup.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; +import reviewme.support.ServiceTest; + +@ServiceTest +class ReviewGroupLookupServiceTest { + + @Autowired + ReviewGroupLookupService reviewGroupLookupService; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Test + void 리뷰_요청_코드로_리뷰_그룹을_조회한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup( + "ted", + "review-me", + "reviewRequestCode", + "groupAccessCode" + )); + + // when + ReviewGroupResponse response = reviewGroupLookupService.getReviewGroupSummary( + reviewGroup.getReviewRequestCode() + ); + + // then + assertAll( + () -> assertThat(response.revieweeName()).isEqualTo(reviewGroup.getReviewee()), + () -> assertThat(response.projectName()).isEqualTo(reviewGroup.getProjectName()) + ); + } + + @Test + void 리뷰_요청_코드에_대한_리뷰_그룹이_존재하지_않을_경우_예외가_발생한다() { + // given, when, then + assertThatThrownBy(() -> reviewGroupLookupService.getReviewGroupSummary("reviewRequestCode")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java new file mode 100644 index 000000000..d7f693de8 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java @@ -0,0 +1,74 @@ +package reviewme.reviewgroup.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.support.ServiceTest; + +@ServiceTest +@ExtendWith(MockitoExtension.class) +class ReviewGroupServiceTest { + + @MockBean + private RandomCodeGenerator randomCodeGenerator; + + @Autowired + private ReviewGroupService reviewGroupService; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Test + void 코드가_중복되는_경우_다시_생성한다() { + // given + reviewGroupRepository.save(new ReviewGroup("reviewee", "project", "0000", "1111")); + given(randomCodeGenerator.generate(anyInt())) + .willReturn("0000") // ReviewRequestCode + .willReturn("AAAA"); + + ReviewGroupCreationRequest request = new ReviewGroupCreationRequest("sancho", "reviewme", "groupAccessCode"); + + // when + ReviewGroupCreationResponse response = reviewGroupService.createReviewGroup(request); + + // then + assertThat(response).isEqualTo(new ReviewGroupCreationResponse("AAAA")); + then(randomCodeGenerator).should(times(2)).generate(anyInt()); + } + + @Test + void 리뷰_요청_코드와_리뷰_확인_코드가_일치하는지_확인한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + reviewGroupRepository.save(new ReviewGroup("reviewee", "project", reviewRequestCode, groupAccessCode)); + + CheckValidAccessRequest request = new CheckValidAccessRequest(reviewRequestCode, groupAccessCode); + CheckValidAccessRequest wrongRequest = new CheckValidAccessRequest(reviewRequestCode, groupAccessCode + "!"); + + // when + CheckValidAccessResponse expected1 = reviewGroupService.checkGroupAccessCode(request); + CheckValidAccessResponse expected2 = reviewGroupService.checkGroupAccessCode(wrongRequest); + + // then + assertAll( + () -> assertThat(expected1.hasAccess()).isTrue(), + () -> assertThat(expected2.hasAccess()).isFalse() + ); + } +} diff --git a/backend/src/test/java/reviewme/support/DatabaseCleaner.java b/backend/src/test/java/reviewme/support/DatabaseCleaner.java new file mode 100644 index 000000000..b90427980 --- /dev/null +++ b/backend/src/test/java/reviewme/support/DatabaseCleaner.java @@ -0,0 +1,42 @@ +package reviewme.support; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Table; +import jakarta.persistence.metamodel.EntityType; +import jakarta.transaction.Transactional; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class DatabaseCleaner { + + private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s"; + private static final String ALTER_FORMAT = "ALTER TABLE %s ALTER COLUMN ID RESTART WITH 1"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void afterPropertiesSet() { + Set> entities = entityManager.getMetamodel().getEntities(); + tableNames = new ArrayList<>(entities.stream() + .filter(entity -> entity.getJavaType().isAnnotationPresent(Table.class)) + .map(entity -> entity.getJavaType().getAnnotation(Table.class).name()) + .toList()); + } + + @Transactional + public void execute() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : tableNames) { + entityManager.createNativeQuery(TRUNCATE_FORMAT.formatted(tableName)).executeUpdate(); + entityManager.createNativeQuery(ALTER_FORMAT.formatted(tableName)).executeUpdate(); + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} diff --git a/backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java b/backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java new file mode 100644 index 000000000..3e2edf4b8 --- /dev/null +++ b/backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java @@ -0,0 +1,15 @@ +package reviewme.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 DatabaseCleanerExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext extensionContext) { + SpringExtension.getApplicationContext(extensionContext) + .getBean(DatabaseCleaner.class) + .execute(); + } +} diff --git a/backend/src/test/java/reviewme/support/ServiceTest.java b/backend/src/test/java/reviewme/support/ServiceTest.java new file mode 100644 index 000000000..34ae4b4fd --- /dev/null +++ b/backend/src/test/java/reviewme/support/ServiceTest.java @@ -0,0 +1,17 @@ +package reviewme.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import reviewme.config.TestConfig; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(webEnvironment = WebEnvironment.NONE, classes = TestConfig.class) +@ExtendWith(DatabaseCleanerExtension.class) +public @interface ServiceTest { +} diff --git a/backend/src/test/java/reviewme/template/domain/SectionTest.java b/backend/src/test/java/reviewme/template/domain/SectionTest.java new file mode 100644 index 000000000..af307e7bd --- /dev/null +++ b/backend/src/test/java/reviewme/template/domain/SectionTest.java @@ -0,0 +1,45 @@ +package reviewme.template.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class SectionTest { + + @Test + void 조건_옵션을_선택하면_섹션이_보인다() { + // given + Section section = new Section(VisibleType.CONDITIONAL, List.of(), 1L, "섹션명", "말머리", 1); + + // when + boolean actual = section.isVisibleBySelectedOptionIds(List.of(1L, 2L, 3L)); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 조건_옵션을_선택하지_않으면_섹션이_보이지_않는다() { + // given + Section section = new Section(VisibleType.CONDITIONAL, List.of(), 1L, "섹션명", "말머리", 1); + + // when + boolean actual = section.isVisibleBySelectedOptionIds(List.of(4L, 5L, 6L)); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 타입이_ALWAYS라면_조건과_상관없이_모두_보인다() { + // given + Section section = new Section(VisibleType.ALWAYS, List.of(), null, "섹션명", "말머리", 1); + + // when + boolean actual = section.isVisibleBySelectedOptionIds(List.of()); + + // then + assertThat(actual).isTrue(); + } +} diff --git a/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java new file mode 100644 index 000000000..0154c40f7 --- /dev/null +++ b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java @@ -0,0 +1,38 @@ +package reviewme.template.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; + +@DataJpaTest +class SectionRepositoryTest { + + @Autowired + private SectionRepository sectionRepository; + @Autowired + private TemplateRepository templateRepository; + + @Test + void 템플릿_아이디로_섹션을_불러온다() { + // given + Section section1 = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "1","말머리", 1)); + Section section2 = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "2","말머리", 1)); + Section section3 = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "3","말머리", 1)); + sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "4","말머리", 1)); + Template template = templateRepository.save( + new Template(List.of(section1.getId(), section2.getId(), section3.getId())) + ); + + // when + List
actual = sectionRepository.findAllByTemplateId(template.getId()); + + // then + assertThat(actual).containsExactly(section1, section2, section3); + } +} diff --git a/backend/src/test/java/reviewme/template/service/TemplateMapperTest.java b/backend/src/test/java/reviewme/template/service/TemplateMapperTest.java new file mode 100644 index 000000000..984df25df --- /dev/null +++ b/backend/src/test/java/reviewme/template/service/TemplateMapperTest.java @@ -0,0 +1,210 @@ +package reviewme.template.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.domain.exception.MissingOptionItemsInOptionGroupException; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.domain.exception.SectionInTemplateNotFoundException; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.QuestionResponse; +import reviewme.template.service.dto.response.SectionResponse; +import reviewme.template.service.dto.response.TemplateResponse; + +@ServiceTest +class TemplateMapperTest { + + @Autowired + TemplateMapper templateMapper; + + @Autowired + TemplateRepository templateRepository; + + @Autowired + SectionRepository sectionRepository; + + @Autowired + QuestionRepository questionRepository; + + @Autowired + OptionGroupRepository optionGroupRepository; + + @Autowired + OptionItemRepository optionItemRepository; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Test + void 리뷰_그룹과_템플릿으로_템플릿_응답을_매핑한다() { + // given + Question question1 = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + Question question2 = new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1); + questionRepository.saveAll(List.of(question1, question2)); + + OptionGroup optionGroup = new OptionGroup(question2.getId(), 1, 2); + optionGroupRepository.save(optionGroup); + + OptionItem optionItem = new OptionItem("선택지", optionGroup.getId(), 1, OptionType.CATEGORY); + optionItemRepository.save(optionItem); + + Section section1 = new Section(VisibleType.ALWAYS, List.of(question1.getId()), null, "섹션명", "말머리1", 1); + Section section2 = new Section(VisibleType.ALWAYS, List.of(question2.getId()), null, "섹션명", "말머리2", 2); + sectionRepository.saveAll(List.of(section1, section2)); + + Template template = new Template(List.of(section1.getId(), section2.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + assertAll( + () -> assertThat(templateResponse.revieweeName()).isEqualTo(reviewGroup.getReviewee()), + () -> assertThat(templateResponse.projectName()).isEqualTo(reviewGroup.getProjectName()), + () -> assertThat(templateResponse.sections()).hasSize(2), + () -> assertThat(templateResponse.sections().get(0).header()).isEqualTo(section1.getHeader()), + () -> assertThat(templateResponse.sections().get(0).questions()).hasSize(1), + () -> assertThat(templateResponse.sections().get(1).header()).isEqualTo(section2.getHeader()), + () -> assertThat(templateResponse.sections().get(1).questions()).hasSize(1) + ); + } + + @Test + void 섹션의_선택된_옵션이_필요없는_경우_제공하지_않는다() { + // given + Question question = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + questionRepository.save(question); + + Section section = new Section(VisibleType.ALWAYS, List.of(question.getId()), null, "섹션명", "말머리", 1); + sectionRepository.save(section); + + Template template = new Template(List.of(section.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + SectionResponse sectionResponse = templateResponse.sections().get(0); + assertThat(sectionResponse.onSelectedOptionId()).isNull(); + } + + @Test + void 가이드라인이_없는_경우_가이드_라인을_제공하지_않는다() { + // given + Question question = new Question(true, QuestionType.TEXT, "질문", null, 1); + questionRepository.save(question); + + OptionGroup optionGroup = new OptionGroup(question.getId(), 1, 2); + optionGroupRepository.save(optionGroup); + + OptionItem optionItem = new OptionItem("선택지", optionGroup.getId(), 1, OptionType.CATEGORY); + optionItemRepository.save(optionItem); + + Section section = new Section(VisibleType.ALWAYS, List.of(question.getId()), null, "섹션명", "말머리", 1); + sectionRepository.save(section); + + Template template = new Template(List.of(section.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + QuestionResponse questionResponse = templateResponse.sections().get(0).questions().get(0); + assertAll( + () -> assertThat(questionResponse.hasGuideline()).isFalse(), + () -> assertThat(questionResponse.guideline()).isNull() + ); + } + + @Test + void 옵션_그룹이_없는_질문의_경우_옵션_그룹을_제공하지_않는다() { + // given + Question question = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + questionRepository.save(question); + + Section section = new Section(VisibleType.ALWAYS, List.of(question.getId()), null, "섹션명", "말머리", 1); + sectionRepository.save(section); + + Template template = new Template(List.of(section.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + QuestionResponse questionResponse = templateResponse.sections().get(0).questions().get(0); + assertThat(questionResponse.optionGroup()).isNull(); + } + + @Test + void 템플릿_매핑_시_템플릿에_제공할_섹션이_없을_경우_예외가_발생한다() { + // given + Template template = new Template(List.of(1L)); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateMapper.mapToTemplateResponse(reviewGroup, template)) + .isInstanceOf(SectionInTemplateNotFoundException.class); + } + + @Test + void 템플릿_매핑_시_옵션_그룹에_해당하는_옵션_아이템이_없을_경우_예외가_발생한다() { + // given + Question question1 = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + Question question2 = new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1); + questionRepository.saveAll(List.of(question1, question2)); + + OptionGroup optionGroup = new OptionGroup(question2.getId(), 1, 2); + optionGroupRepository.save(optionGroup); + + Section section1 = new Section(VisibleType.ALWAYS, List.of(question1.getId()), null, "섹션명", "말머리", 1); + Section section2 = new Section(VisibleType.ALWAYS, List.of(question2.getId()), null, "섹션명", "말머리", 2); + sectionRepository.saveAll(List.of(section1, section2)); + + Template template = new Template(List.of(section1.getId(), section2.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateMapper.mapToTemplateResponse(reviewGroup, template)) + .isInstanceOf(MissingOptionItemsInOptionGroupException.class); + } +} diff --git a/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java b/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java new file mode 100644 index 000000000..8d84d6f82 --- /dev/null +++ b/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java @@ -0,0 +1,43 @@ +package reviewme.template.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.exception.TemplateNotFoundByReviewGroupException; + +@ServiceTest +class TemplateServiceTest { + + @Autowired + TemplateService templateService; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Test + void 잘못된_리뷰_요청_코드로_리뷰_작성폼을_요청할_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateService.generateReviewForm(reviewGroup.getReviewRequestCode() + " ")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } + + @Test + void 리뷰이에게_작성될_리뷰_양식_생성_시_저장된_템플릿이_없을_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateService.generateReviewForm(reviewGroup.getReviewRequestCode())) + .isInstanceOf(TemplateNotFoundByReviewGroupException.class); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 000000000..b941b41ba --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,34 @@ +spring: + datasource: + url: jdbc:h2:mem:test + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + hibernate: + ddl-auto: update + +springdoc: + swagger-ui: + path: /api-docs + operations-sorter: alpha + tags-sorter: alpha + +logging: + config: classpath:logback-spring.xml + file: + path: logs + name: review-me.log + level: + springframework: DEBUG + logback: + rolling-policy: + max-history: 100 + file-name-pattern: review-me.%d{yyyy-MM-dd}.log + pattern: + console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:- } [%15.15t] %-40.40logger{39} : %m%n%wEx" diff --git a/backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 000000000..f2c62a493 --- /dev/null +++ b/backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,11 @@ +|=== +|필드명|타입|필수 여부|설명 + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}false{{/optional}}{{^optional}}true{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} + +|=== diff --git a/backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet b/backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet new file mode 100644 index 000000000..e45909715 --- /dev/null +++ b/backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet @@ -0,0 +1,10 @@ +|=== +|헤더 이름|필수 여부|설명 + +{{#headers}} +|{{#tableCellContent}}{{name}}{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}false{{/optional}}{{^optional}}true{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/headers}} + +|=== diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 000000000..b6d283909 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,67 @@ +const path = require('path'); + +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'plugin:import/recommended', + 'plugin:react/recommended', + 'prettier', + ], + ignorePatterns: [ + 'dist', + '.eslintrc.cjs', + 'webpack.config.js', + 'jest.config.js', + 'jest.polyfills.js', + 'jest.setup.js', + 'tsconfig.json', + ], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/no-unknown-property': ['error', { ignore: ['css'] }], + 'import/order': [ + 'error', + { + 'newlines-between': 'always', + groups: [['builtin', 'external'], 'internal', 'parent', 'sibling', 'index'], + pathGroups: [ + { + pattern: 'next', + group: 'builtin', + }, + { + pattern: 'react', + group: 'builtin', + }, + { + pattern: '@MyDesignSystem/**', + group: 'internal', + }, + { + pattern: 'src/**', + group: 'internal', + }, + ], + pathGroupsExcludedImportTypes: ['src/**', '@MyDesignSystem/**'], + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + }, + settings: { + 'import/resolver': { + alias: { + map: [['@', path.resolve(__dirname, 'src')]], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, +}; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..090910b92 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist + +yarn-error.log +.env +.env.sentry-build-plugin diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 000000000..17c2d2067 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "endOfLine": "auto", + "singleQuote": true, + "semi": true, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 120, + "bracketSpacing": true, + "arrowParens": "always", + "prefer-const": true +} \ No newline at end of file diff --git a/frontend/.stylelintrc.json b/frontend/.stylelintrc.json new file mode 100644 index 000000000..9c644d2c4 --- /dev/null +++ b/frontend/.stylelintrc.json @@ -0,0 +1,13 @@ +{ + "extends": ["stylelint-config-clean-order"], + "plugins": ["stylelint-order"], + "customSyntax": "@stylelint/postcss-css-in-js", + "rules": { + "declaration-empty-line-before": [ + "never", + { + "ignore": ["after-declaration"] + } + ] + } +} diff --git a/frontend/babel.config.json b/frontend/babel.config.json new file mode 100644 index 000000000..8e3a5e35b --- /dev/null +++ b/frontend/babel.config.json @@ -0,0 +1,8 @@ +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic", "importSource": "@emotion/react" }], + "@babel/preset-typescript" + ], + "plugins": ["@emotion"] +} diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 000000000..3891f259d --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,13 @@ +const { pathsToModuleNameMapper } = require('ts-jest'); + +const { compilerOptions } = require('./tsconfig'); + +module.exports = { + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }), + setupFiles: ['./jest.polyfills.js'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['./jest.setup.js'], + testEnvironmentOptions: { + customExportConditions: [''], + }, +}; diff --git a/frontend/jest.polyfills.js b/frontend/jest.polyfills.js new file mode 100644 index 000000000..03a705a59 --- /dev/null +++ b/frontend/jest.polyfills.js @@ -0,0 +1,21 @@ +import 'dotenv/config'; +const { TextDecoder, TextEncoder, ReadableStream } = require('node:util'); + +Object.defineProperties(globalThis, { + TextDecoder: { value: TextDecoder }, + TextEncoder: { value: TextEncoder }, + ReadableStream: { value: ReadableStream }, +}); + +const { Blob, File } = require('node:buffer'); +const { fetch, Headers, FormData, Request, Response } = require('undici'); + +Object.defineProperties(globalThis, { + fetch: { value: fetch, writable: true }, + Blob: { value: Blob }, + File: { value: File }, + Headers: { value: Headers }, + FormData: { value: FormData }, + Request: { value: Request }, + Response: { value: Response }, +}); diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js new file mode 100644 index 000000000..ed5421416 --- /dev/null +++ b/frontend/jest.setup.js @@ -0,0 +1,5 @@ +import server from './src/mocks/server'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..7c1e38e29 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,79 @@ +{ + "name": "frontend", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "dev": "webpack-dev-server --mode=development --open --hot --progress", + "start": "webpack serve --open --config webpack.config.js", + "build": "webpack --config webpack.config.js", + "serve": "http-server ./dist", + "lint:styles": "stylelint \"src/**/styles.ts\" --fix", + "test": "jest" + }, + "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@sentry/react": "^8.23.0", + "@sentry/webpack-plugin": "^2.21.1", + "@tanstack/react-query": "^5.51.1", + "@tanstack/react-query-devtools": "^5.51.1", + "dotenv-webpack": "^8.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-router": "^6.24.1", + "react-router-dom": "^6.24.1", + "recoil": "^0.7.7" + }, + "devDependencies": { + "@babel/core": "^7.24.7", + "@babel/preset-env": "^7.24.7", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@emotion/babel-plugin": "^11.11.0", + "@stylelint/postcss-css-in-js": "^0.38.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "babel-loader": "^9.1.3", + "clean-webpack-plugin": "^4.0.0", + "dotenv": "^16.4.5", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.8", + "history": "^5.3.0", + "html-webpack-plugin": "^5.6.0", + "http-server": "^14.1.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-fixed-jsdom": "^0.0.2", + "msw": "2.3.2", + "postcss-syntax": "^0.36.2", + "prettier": "^3.3.2", + "stylelint": "^16.7.0", + "stylelint-config-clean-order": "^6.1.0", + "stylelint-order": "^6.0.4", + "ts-jest": "^29.2.4", + "typescript": "^5.5.3", + "undici": "5.0.0", + "webpack": "^5.92.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.0.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 000000000..ee0cb1c0b --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + REVIEW ME + + + +
+ + + \ No newline at end of file diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..6185b3c32 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,281 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.4'; +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..bb182911e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,30 @@ +import { Outlet } from 'react-router'; + +import { PageLayout, Sidebar, Topbar, SideModal, Footer, Main } from './components'; +import Breadcrumb from './components/common/Breadcrumb'; +import { useSidebar } from './hooks'; +import useBreadcrumbPaths from './hooks/useBreadcrumbPaths'; + +const App = () => { + const { isSidebarHidden, isSidebarModalOpen, closeSidebar, openSidebar } = useSidebar(); + + const breadcrumbPathList = useBreadcrumbPaths(); + + return ( + + {/* {isSidebarModalOpen && ( + + + + )} */} + + {breadcrumbPathList.length > 1 && } +
1)}> + +
+