diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/chore-template.md b/.github/ISSUE_TEMPLATE/chore-template.md new file mode 100644 index 00000000..f648ffba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore-template.md @@ -0,0 +1,13 @@ +--- +name: "Chore Template" +about: 기타 이슈 템플릿 +title: '[CHORE] ' +labels: chore +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) diff --git a/.github/ISSUE_TEMPLATE/docs-template.md b/.github/ISSUE_TEMPLATE/docs-template.md new file mode 100644 index 00000000..d31516ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs-template.md @@ -0,0 +1,13 @@ +--- +name: "Docs Template" +about: 문서화 이슈 템플릿 +title: '[DOCS] ' +labels: docs +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) diff --git a/.github/ISSUE_TEMPLATE/feat-template.md b/.github/ISSUE_TEMPLATE/feat-template.md new file mode 100644 index 00000000..cf2f908b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat-template.md @@ -0,0 +1,13 @@ +--- +name: "Feat Template" +about: 기능 추가 이슈 템플릿 +title: "[FEAT] " +labels: feat +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) diff --git a/.github/ISSUE_TEMPLATE/fix-template.md b/.github/ISSUE_TEMPLATE/fix-template.md new file mode 100644 index 00000000..04665164 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/fix-template.md @@ -0,0 +1,13 @@ +--- +name: "Fix Template" +about: 기능 수정 이슈 템플릿 +title: "[FIX] " +labels: fix +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) diff --git a/.github/ISSUE_TEMPLATE/hotfix-template.md b/.github/ISSUE_TEMPLATE/hotfix-template.md new file mode 100644 index 00000000..76b3d38b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hotfix-template.md @@ -0,0 +1,15 @@ +--- +name: "Hot-Fix Template" +about: 핫픽스 이슈 템플릿 +title: "[HOTFIX] " +labels: hotfix +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) + +- ASAP diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md deleted file mode 100644 index ad0c1a88..00000000 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: Issue Template -about: Describe this issue template's purpose here. -title: '' -labels: '' -assignees: '' - ---- - -### ✨ Description - -### ✨ Time(마감기한) diff --git a/.github/ISSUE_TEMPLATE/refactor-template.md b/.github/ISSUE_TEMPLATE/refactor-template.md new file mode 100644 index 00000000..87e55ee4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-template.md @@ -0,0 +1,13 @@ +--- +name: "Refactor Template" +about: 리팩토링 이슈 템플릿 +title: "[REFACTOR] " +labels: refactor +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) diff --git a/.github/ISSUE_TEMPLATE/test-template.md b/.github/ISSUE_TEMPLATE/test-template.md new file mode 100644 index 00000000..144a5f92 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test-template.md @@ -0,0 +1,13 @@ +--- +name: "Test Template" +about: 테스트 이슈 템플릿 +title: "[TEST] " +labels: test +assignees: '' + +--- + +### ✨ Description + + +### ✨ Time(마감기한) diff --git a/.github/workflows/Auto_Issue_Setting.yml b/.github/workflows/Auto_Issue_Setting.yml new file mode 100644 index 00000000..1410e400 --- /dev/null +++ b/.github/workflows/Auto_Issue_Setting.yml @@ -0,0 +1,15 @@ +name: Auto Issue Setting + +on: + issues: + types: [opened] + +jobs: + auto-assign: + runs-on: ubuntu-latest + steps: + - name: Auto-assign issue creator + uses: pozil/auto-assign-issue@v2 + with: + repo-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + assignees: ${{ github.actor }} diff --git a/.github/workflows/Dev_CD.yml b/.github/workflows/Dev_CD.yml new file mode 100644 index 00000000..8718cc23 --- /dev/null +++ b/.github/workflows/Dev_CD.yml @@ -0,0 +1,72 @@ +name: dev-cd + +on: + push: + branches: + - "develop" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Develop Branch + uses: actions/checkout@v4 + with: + ref: "develop" + + - name: Setting dev-secret.yml + run: | + echo "${{ secrets.DEV_SECRET_YML }}" > ./src/main/resources/dev-secret.yml + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Clean And Test With Gradle + run: ./gradlew clean test + + - name: bootJar with Gradle + run: ./gradlew bootJar --info + + - name: Change artifact file name + run: mv build/libs/*.jar build/libs/app.jar + + - name: Upload artifact file + uses: actions/upload-artifact@v4 + with: + name: app-artifact + path: ./build/libs/app.jar + if-no-files-found: error + + - name: Upload deploy scripts + uses: actions/upload-artifact@v4 + with: + name: deploy-scripts + path: ./scripts/dev/ + if-no-files-found: error + + deploy: + needs: build + runs-on: dev + + steps: + - name: Download artifact file + uses: actions/download-artifact@v4 + with: + name: app-artifact + path: ~/app + + - name: Download deploy scripts + uses: actions/download-artifact@v4 + with: + name: deploy-scripts + path: ~/app/scripts + + - name: Replace application to latest + run: sudo sh ~/app/scripts/replace-new-version.sh diff --git a/.github/workflows/Dev_CI.yml b/.github/workflows/Dev_CI.yml new file mode 100644 index 00000000..400938fb --- /dev/null +++ b/.github/workflows/Dev_CI.yml @@ -0,0 +1,61 @@ +name: dev-ci + +on: + pull_request: + branches: + - develop + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 3 + env: + TEST_REPORT: true + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant Permission + run: chmod +x ./gradlew + + - name: Clean And Test With Gradle + run: ./gradlew clean test + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: ${{ github.workspace }}/build/test-results/**/*.xml + + - name: JUnit Report Action + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: ${{ github.workspace }}/build/test-results/**/*.xml + + - name: Report test Coverage to PR + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + if: always() + with: + title: 📝 Test Coverage Report + paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 80 + min-coverage-changed-files: 80 + update-comment: true + debug-mode: true diff --git a/.github/workflows/PR_Comment_Notification.yml b/.github/workflows/PR_Comment_Notification.yml index 952dd7ab..e1c8236b 100644 --- a/.github/workflows/PR_Comment_Notification.yml +++ b/.github/workflows/PR_Comment_Notification.yml @@ -1,64 +1,122 @@ -name: Discord Notification on PR Comment +name: Discord Notification on Issue & PR Comment on: issue_comment: - types: [ created, edited ] + types: [ created ] + pull_request_review: + types: [ submitted ] jobs: notify-discord: - if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '/noti') }} runs-on: ubuntu-latest steps: - name: Set Environment Variables - env: - TITLE: ${{ github.event.issue.title }} run: | - echo "AVATAR_URL=${{ secrets.DISCORD_AVATAR_URL }}" >> $GITHUB_ENV - echo "COMMENT_BODY= 🤚백엔드 친구들 모여라~ 🔊" >> $GITHUB_ENV - echo "USERNAME=망나뇽" >> $GITHUB_ENV echo "WEB_HOOK=${{ secrets.DISCORD_WEB_HOOK }}" >> $GITHUB_ENV + - name: Set Environment Variables for Issue + if: ${{ !github.event.issue.pull_request && contains(github.event.comment.body, '/noti') }} + run: | + echo "CONTENT=$(echo '${{ github.event.comment.body }}' | base64 -w 0)" >> $GITHUB_ENV + echo "POST_URL=${{ github.event.issue.html_url }}" >> $GITHUB_ENV + echo "COMMENT_AUTHOR=${{ github.event.comment.user.login }}" >> $GITHUB_ENV + echo "COMMENT_AUTHOR_AVATAR=${{ github.event.comment.user.avatar_url }}" >> $GITHUB_ENV + echo "POST_AUTHOR=${{ github.event.issue.user.login }}" >> $GITHUB_ENV + echo "POST_AUTHOR_AVATAR=${{ github.event.issue.user.avatar_url }}" >> $GITHUB_ENV + echo "TITLE=${{ github.event.issue.title }}" >> $GITHUB_ENV + echo "POST_TYPE=Issue" >> $GITHUB_ENV + + - name: Set Environment Variables for PR Review Comments + if: ${{ contains(github.event.review.body, '/noti') }} + run: | + echo "CONTENT=$(echo '${{ github.event.review.body }}' | base64 -w 0)" >> $GITHUB_ENV + echo "POST_URL=${{ github.event.pull_request._links.html.href }}" >> $GITHUB_ENV + echo "COMMENT_AUTHOR=${{ github.event.review.user.login }}" >> $GITHUB_ENV + echo "COMMENT_AUTHOR_AVATAR=${{ github.event.review.user.avatar_url }}" >> $GITHUB_ENV + echo "POST_AUTHOR=${{ github.event.pull_request.base.user.login }}" >> $GITHUB_ENV + echo "POST_AUTHOR_AVATAR=${{ github.event.pull_request.base.user.avatar_url }}" >> $GITHUB_ENV + echo "TITLE=${{ github.event.pull_request.title }}" >> $GITHUB_ENV + echo "POST_TYPE=Pull Request" >> $GITHUB_ENV + + - name: Set Environment Variables for PR Comments + if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '/noti') }} + run: | + echo "CONTENT=$(echo '${{ github.event.comment.body }}' | base64 -w 0)" >> $GITHUB_ENV + echo "POST_URL=${{ github.event.issue.html_url }}" >> $GITHUB_ENV + echo "COMMENT_AUTHOR=${{ github.event.comment.user.login }}" >> $GITHUB_ENV + echo "COMMENT_AUTHOR_AVATAR=${{ github.event.comment.user.avatar_url }}" >> $GITHUB_ENV + echo "POST_AUTHOR=${{ github.event.issue.user.login }}" >> $GITHUB_ENV + echo "POST_AUTHOR_AVATAR=${{ github.event.issue.user.avatar_url }}" >> $GITHUB_ENV + echo "TITLE=${{ github.event.issue.title }}" >> $GITHUB_ENV + echo "POST_TYPE=Pull Request" >> $GITHUB_ENV + + - name: Set Discord Content + if: ${{ env.CONTENT }} + env: + CONTENT: ${{ env.CONTENT }} + run: | + IFS=',' read -r -a user_github_discord_pair <<< "${{ secrets.PAIR_GITHUB_DISCORD_USER }}" + IFS=',' read -r -a role_github_discord_pair <<< "${{ secrets.PAIR_GITHUB_DISCORD_ROLE }}" + content=$(echo "$CONTENT" | base64 --decode) + + for ((i = 0; i < ${#user_github_discord_pair[@]}; i += 2)); do + search=${user_github_discord_pair[i]} + replace=${user_github_discord_pair[i+1]} + content=$(echo "$content" | sed "s|@${search}|<@${replace}>|g") + done + for ((i = 0; i < ${#role_github_discord_pair[@]}; i += 2)); do + search=${role_github_discord_pair[i]} + replace=${role_github_discord_pair[i+1]} + content=$(echo "$content" | sed "s|@${search}|<@\&${replace}>|g") + done + echo "DISCORD_CONTENT=$(echo $content | base64 -w 0)" >> $GITHUB_ENV + - name: Notify Discord + if: ${{ env.WEB_HOOK && env.POST_URL && env.DISCORD_CONTENT }} env: - COMMENT_BODY: ${{ env.COMMENT_BODY }} - COMMENT_AUTHOR: ${{ github.event.comment.user.login }} - PR_URL: ${{ github.event.issue.pull_request.html_url }} - AUTHOR_URL: ${{ github.event.comment.user.avatar_url }} - AVATAR_URL: ${{ env.AVATAR_URL }} - CONTENT: ${{ github.event.comment.body }} - USERNAME: ${{ env.USERNAME }} + DISCORD_CONTENT: ${{ env.DISCORD_CONTENT }} + COMMENT_AUTHOR: ${{ env.COMMENT_AUTHOR }} + COMMENT_AUTHOR_AVATAR: ${{ env.COMMENT_AUTHOR_AVATAR }} + TITLE: ${{ env.TITLE }} + POST_URL: ${{ env.POST_URL }} + POST_AUTHOR: ${{ env.POST_AUTHOR }} + POST_AUTHOR_AVATAR: ${{ env.POST_AUTHOR_AVATAR }} + POST_TYPE: ${{ env.POST_TYPE }} WEB_HOOK: ${{ env.WEB_HOOK }} run: | + CONTENT_DECODED=$(echo "$DISCORD_CONTENT" | base64 --decode) if [ -n "$WEB_HOOK" ]; then JSON_PAYLOAD=$(jq -n \ - --arg content "$CONTENT" \ - --arg description "$COMMENT_BODY" \ - --arg username "$USERNAME" \ - --arg avatar_url "$AVATAR_URL" \ - --arg title "Pull Request Command" \ - --arg url "$PR_URL" \ - --arg author_url "$AUTHOR_URL" \ - --arg author_name "$COMMENT_AUTHOR" \ + --arg content "$CONTENT_DECODED" \ + --arg comment_author "$COMMENT_AUTHOR" \ + --arg comment_author_avatar "$COMMENT_AUTHOR_AVATAR" \ + --arg embeds_title "$TITLE" \ + --arg post_url "$POST_URL" \ + --arg post_author "$POST_AUTHOR" \ + --arg post_author_avatar "$POST_AUTHOR_AVATAR" \ + --arg description "$POST_TYPE" \ --arg color "5814783" \ --arg timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ '{ content: $content, - username: $username, - avatar_url: $avatar_url, + username: $comment_author, + avatar_url: $comment_author_avatar, embeds: [{ - title: $title, - url: $url, + title: $embeds_title, + url: $post_url, author: { - name: $author_name, - icon_url: $author_url + name: $post_author, + icon_url: $post_author_avatar }, description: $description, color: ($color | tonumber), timestamp: $timestamp - }] + }], + allowed_mentions: { + parse: ["users", "roles"] + } }') - curl -X POST -H "Content-Type: application/json" -d "$JSON_PAYLOAD" "$WEB_HOOK" else echo "No matching title found. Skipping notification." diff --git a/.github/workflows/Prod_CD.yml b/.github/workflows/Prod_CD.yml new file mode 100644 index 00000000..20e0c517 --- /dev/null +++ b/.github/workflows/Prod_CD.yml @@ -0,0 +1,72 @@ +name: prod-cd + +on: + push: + branches: + - "main" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Develop Branch + uses: actions/checkout@v4 + with: + ref: "main" + + - name: Setting prod-secret.yml + run: | + echo "${{ secrets.PROD_SECRET_YML }}" > ./src/main/resources/prod-secret.yml + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Clean And Test With Gradle + run: ./gradlew clean test + + - name: bootJar with Gradle + run: ./gradlew bootJar --info + + - name: Change artifact file name + run: mv build/libs/*.jar build/libs/app.jar + + - name: Upload artifact file + uses: actions/upload-artifact@v4 + with: + name: app-artifact + path: ./build/libs/app.jar + if-no-files-found: error + + - name: Upload deploy scripts + uses: actions/upload-artifact@v4 + with: + name: deploy-scripts + path: ./scripts/prod/ + if-no-files-found: error + + deploy: + needs: build + runs-on: prod + + steps: + - name: Download artifact file + uses: actions/download-artifact@v4 + with: + name: app-artifact + path: ~/app + + - name: Download deploy scripts + uses: actions/download-artifact@v4 + with: + name: deploy-scripts + path: ~/app/scripts + + - name: Replace application to latest + run: sudo sh ~/app/scripts/replace-new-version.sh diff --git a/.github/workflows/Prod_CI.yml b/.github/workflows/Prod_CI.yml new file mode 100644 index 00000000..ebcda075 --- /dev/null +++ b/.github/workflows/Prod_CI.yml @@ -0,0 +1,29 @@ +name: prod-ci + +on: + pull_request: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 3 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant Permission + run: chmod +x ./gradlew + + - name: Clean And Test With Gradle + run: ./gradlew clean test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4e91a957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +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/ + +### Rest Docs +/src/main/resources/static/docs/openapi3.yaml diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..bdcae96c --- /dev/null +++ b/build.gradle @@ -0,0 +1,108 @@ +plugins { + id 'java' + id 'jacoco' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' + + // Rest Docs & Swagger + id 'com.epages.restdocs-api-spec' version '0.18.2' + id 'org.hidetake.swagger.generator' version '2.18.2' +} + +group = 'com' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Rest Docs & Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + testImplementation 'io.rest-assured:rest-assured:5.5.0' + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2' + testImplementation 'com.epages:restdocs-api-spec-restassured:0.18.2' + + // Excel Export + implementation 'org.apache.poi:poi-ooxml:5.2.3' + implementation 'org.apache.poi:poi:5.2.3' +} + +bootJar { + dependsOn("openapi3") +} + +jacoco { + toolVersion = '0.8.9' +} + +tasks.named('test') { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(false) + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + "com/debatetimer/**/dto/**" + ]) + })) + } + + onlyIf { + return System.getenv('TEST_REPORT') == 'true' + } +} + +generateSwaggerUI { + dependsOn("openapi3") + + delete(fileTree("src/main/resources/static/docs/") { + exclude(".gitkeep") + }) + copy { + from("build/resources/main/static/docs/") + into("src/main/resources/static/docs/") + } +} + +openapi3 { + server = "http://localhost:8080" + title = "토론 타이머 API" + description = "토론 타이머 API" + version = "0.0.1" + format = "yaml" + outputDirectory = "build/resources/main/static/docs" +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e2847c82 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..f5feea6d --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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 -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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/scripts/dev/replace-new-version.sh b/scripts/dev/replace-new-version.sh new file mode 100644 index 00000000..e86b27e8 --- /dev/null +++ b/scripts/dev/replace-new-version.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +PID=$(lsof -t -i:8080) + +# 프로세스 종료 +if [ -z "$PID" ]; then + echo "No process is using port 8080." +else + echo "Killing process with PID: $PID" + kill -15 "$PID" + + # 직전 명령(프로세스 종료 명령)이 정상 동작했는지 확인 + if [ $? -eq 0 ]; then + echo "Process $PID terminated successfully." + else + echo "Failed to terminate process $PID." + fi +fi + +JAR_FILE=$(ls /home/ubuntu/app/*.jar | head -n 1) + +sudo nohup java -Dspring.profiles.active=dev -Duser.timezone=Asia/Seoul -Dserver.port=8080 -jar "$JAR_FILE" & diff --git a/scripts/prod/replace-new-version.sh b/scripts/prod/replace-new-version.sh new file mode 100644 index 00000000..d78b646f --- /dev/null +++ b/scripts/prod/replace-new-version.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +PID=$(lsof -t -i:8080) + +# 프로세스 종료 +if [ -z "$PID" ]; then + echo "No process is using port 8080." +else + echo "Killing process with PID: $PID" + kill -15 "$PID" + + # 직전 명령(프로세스 종료 명령)이 정상 동작했는지 확인 + if [ $? -eq 0 ]; then + echo "Process $PID terminated successfully." + else + echo "Failed to terminate process $PID." + fi +fi + +JAR_FILE=$(ls /home/ubuntu/app/*.jar | head -n 1) + +sudo nohup java -Dspring.profiles.active=prod -Duser.timezone=Asia/Seoul -Dserver.port=8080 -jar "$JAR_FILE" & diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..2bfef168 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'debatetimer' diff --git a/src/lombok.config b/src/lombok.config new file mode 100644 index 00000000..7a21e880 --- /dev/null +++ b/src/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/src/main/java/com/debatetimer/DebateTimerApplication.java b/src/main/java/com/debatetimer/DebateTimerApplication.java new file mode 100644 index 00000000..0d8907b7 --- /dev/null +++ b/src/main/java/com/debatetimer/DebateTimerApplication.java @@ -0,0 +1,13 @@ +package com.debatetimer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DebateTimerApplication { + + public static void main(String[] args) { + SpringApplication.run(DebateTimerApplication.class, args); + } + +} diff --git a/src/main/java/com/debatetimer/config/AuthMemberArgumentResolver.java b/src/main/java/com/debatetimer/config/AuthMemberArgumentResolver.java new file mode 100644 index 00000000..0a542f60 --- /dev/null +++ b/src/main/java/com/debatetimer/config/AuthMemberArgumentResolver.java @@ -0,0 +1,44 @@ +package com.debatetimer.config; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.custom.DTException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +@Slf4j +@RequiredArgsConstructor +public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + try { + long memberId = Long.parseLong(webRequest.getParameter("memberId")); + return memberRepository.getById(memberId); + } catch (DTException | NumberFormatException exception) { + log.warn(exception.getMessage()); + throw new DTClientErrorException(ClientErrorCode.UNAUTHORIZED_MEMBER); + } + } +} + + diff --git a/src/main/java/com/debatetimer/config/CorsConfig.java b/src/main/java/com/debatetimer/config/CorsConfig.java new file mode 100644 index 00000000..e767ccc2 --- /dev/null +++ b/src/main/java/com/debatetimer/config/CorsConfig.java @@ -0,0 +1,33 @@ +package com.debatetimer.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + private final String[] corsOrigin; + + public CorsConfig(@Value("${cors.origin}") String[] corsOrigin) { + this.corsOrigin = corsOrigin; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns(corsOrigin) + .allowedMethods( + HttpMethod.GET.name(), + HttpMethod.POST.name(), + HttpMethod.PATCH.name(), + HttpMethod.DELETE.name(), + HttpMethod.OPTIONS.name() + ) + .allowCredentials(true) + .allowedHeaders("*"); + } +} + diff --git a/src/main/java/com/debatetimer/config/WebConfig.java b/src/main/java/com/debatetimer/config/WebConfig.java new file mode 100644 index 00000000..a983c443 --- /dev/null +++ b/src/main/java/com/debatetimer/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.debatetimer.config; + +import com.debatetimer.repository.member.MemberRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final MemberRepository memberRepository; + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(new AuthMemberArgumentResolver(memberRepository)); + } +} diff --git a/src/main/java/com/debatetimer/controller/auth/AuthMember.java b/src/main/java/com/debatetimer/controller/auth/AuthMember.java new file mode 100644 index 00000000..b8a31964 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/auth/AuthMember.java @@ -0,0 +1,11 @@ +package com.debatetimer.controller.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} diff --git a/src/main/java/com/debatetimer/controller/member/MemberController.java b/src/main/java/com/debatetimer/controller/member/MemberController.java new file mode 100644 index 00000000..94b9b393 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/member/MemberController.java @@ -0,0 +1,33 @@ +package com.debatetimer.controller.member; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.member.MemberCreateRequest; +import com.debatetimer.dto.member.MemberCreateResponse; +import com.debatetimer.dto.member.TableResponses; +import com.debatetimer.service.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/api/table") + public TableResponses getTables(@AuthMember Member member) { + return memberService.getTables(member.getId()); + } + + @PostMapping("/api/member") + @ResponseStatus(HttpStatus.CREATED) + public MemberCreateResponse createMember(@RequestBody MemberCreateRequest request) { + return memberService.createMember(request); + } +} diff --git a/src/main/java/com/debatetimer/controller/parliamentary/ParliamentaryController.java b/src/main/java/com/debatetimer/controller/parliamentary/ParliamentaryController.java new file mode 100644 index 00000000..1589899e --- /dev/null +++ b/src/main/java/com/debatetimer/controller/parliamentary/ParliamentaryController.java @@ -0,0 +1,62 @@ +package com.debatetimer.controller.parliamentary; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.parliamentary.request.ParliamentaryTableCreateRequest; +import com.debatetimer.dto.parliamentary.response.ParliamentaryTableResponse; +import com.debatetimer.service.parliamentary.ParliamentaryService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class ParliamentaryController { + + private final ParliamentaryService parliamentaryService; + + @PostMapping("/api/table/parliamentary") + @ResponseStatus(HttpStatus.CREATED) + public ParliamentaryTableResponse save( + @Valid @RequestBody ParliamentaryTableCreateRequest tableCreateRequest, + @AuthMember Member member + ) { + return parliamentaryService.save(tableCreateRequest, member); + } + + @GetMapping("/api/table/parliamentary/{tableId}") + @ResponseStatus(HttpStatus.OK) + public ParliamentaryTableResponse getTable( + @PathVariable Long tableId, + @AuthMember Member member + ) { + return parliamentaryService.findTable(tableId, member); + } + + @PutMapping("/api/table/parliamentary/{tableId}") + @ResponseStatus(HttpStatus.OK) + public ParliamentaryTableResponse updateTable( + @Valid @RequestBody ParliamentaryTableCreateRequest tableCreateRequest, + @PathVariable Long tableId, + @AuthMember Member member + ) { + return parliamentaryService.updateTable(tableCreateRequest, tableId, member); + } + + @DeleteMapping("/api/table/parliamentary/{tableId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteTable( + @PathVariable Long tableId, + @AuthMember Member member + ) { + parliamentaryService.deleteTable(tableId, member); + } +} diff --git a/src/main/java/com/debatetimer/domain/BoxType.java b/src/main/java/com/debatetimer/domain/BoxType.java new file mode 100644 index 00000000..88771570 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/BoxType.java @@ -0,0 +1,21 @@ +package com.debatetimer.domain; + +import java.util.Set; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum BoxType { + + OPENING(Set.of(Stance.PROS, Stance.CONS)), + REBUTTAL(Set.of(Stance.PROS, Stance.CONS)), + CROSS(Set.of(Stance.PROS, Stance.CONS)), + CLOSING(Set.of(Stance.PROS, Stance.CONS)), + TIME_OUT(Set.of(Stance.NEUTRAL)), + ; + + private final Set availableStances; + + public boolean isAvailable(Stance stance) { + return availableStances.contains(stance); + } +} diff --git a/src/main/java/com/debatetimer/domain/Stance.java b/src/main/java/com/debatetimer/domain/Stance.java new file mode 100644 index 00000000..bc9dc559 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/Stance.java @@ -0,0 +1,8 @@ +package com.debatetimer.domain; + +public enum Stance { + + PROS, + CONS, + NEUTRAL, +} diff --git a/src/main/java/com/debatetimer/domain/member/Member.java b/src/main/java/com/debatetimer/domain/member/Member.java new file mode 100644 index 00000000..29b9a3d1 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/member/Member.java @@ -0,0 +1,48 @@ +package com.debatetimer.domain.member; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member { + + private static final String NICKNAME_REGEX = "^[a-zA-Z가-힣 ]+$"; + public static final int NICKNAME_MAX_LENGTH = 10; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String nickname; + + public Member(long id, String nickname) { + validate(nickname); + this.id = id; + this.nickname = nickname; + } + + public Member(String nickname) { + validate(nickname); + this.nickname = nickname; + } + + private void validate(String nickname) { + if (nickname.isEmpty() || nickname.length() > NICKNAME_MAX_LENGTH) { + throw new DTClientErrorException(ClientErrorCode.INVALID_MEMBER_NICKNAME_LENGTH); + } + if (!nickname.matches(NICKNAME_REGEX)) { + throw new DTClientErrorException(ClientErrorCode.INVALID_MEMBER_NICKNAME_FORM); + } + } +} diff --git a/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTable.java b/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTable.java new file mode 100644 index 00000000..5304647e --- /dev/null +++ b/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTable.java @@ -0,0 +1,74 @@ +package com.debatetimer.domain.parliamentary; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ParliamentaryTable { + + private static final String NAME_REGEX = "^[a-zA-Z가-힣0-9 ]+$"; + public static final int NAME_MAX_LENGTH = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @NotNull + private String name; + + @NotNull + private String agenda; + + @NotNull + private int duration; + + public ParliamentaryTable(Member member, String name, String agenda, int duration) { + validate(name, duration); + this.member = member; + this.name = name; + this.agenda = agenda; + this.duration = duration; + } + + private void validate(String name, int duration) { + if (name.isBlank() || name.length() > NAME_MAX_LENGTH) { + throw new DTClientErrorException(ClientErrorCode.INVALID_TABLE_NAME_LENGTH); + } + if (!name.matches(NAME_REGEX)) { + throw new DTClientErrorException(ClientErrorCode.INVALID_TABLE_NAME_FORM); + } + if (duration <= 0) { + throw new DTClientErrorException(ClientErrorCode.INVALID_TABLE_TIME); + } + } + + public void update(ParliamentaryTable renewTable) { + this.name = renewTable.getName(); + this.agenda = renewTable.getAgenda(); + this.duration = renewTable.getDuration(); + } + + public boolean isOwner(long memberId) { + return Objects.equals(this.member.getId(), memberId); + } +} diff --git a/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBox.java b/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBox.java new file mode 100644 index 00000000..b2f8236d --- /dev/null +++ b/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBox.java @@ -0,0 +1,74 @@ +package com.debatetimer.domain.parliamentary; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ParliamentaryTimeBox { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "table_id") + private ParliamentaryTable parliamentaryTable; + + @NotNull + private int sequence; + + @NotNull + @Enumerated(EnumType.STRING) + private Stance stance; + + @NotNull + @Enumerated(EnumType.STRING) + private BoxType type; + + @NotNull + private int time; + + private Integer speaker; + + public ParliamentaryTimeBox(ParliamentaryTable parliamentaryTable, int sequence, Stance stance, BoxType type, + int time, Integer speaker) { + validate(sequence, time, stance, type); + this.parliamentaryTable = parliamentaryTable; + this.sequence = sequence; + this.stance = stance; + this.type = type; + this.time = time; + this.speaker = speaker; + } + + private void validate(int sequence, int time, Stance stance, BoxType boxType) { + if (sequence <= 0) { + throw new DTClientErrorException(ClientErrorCode.INVALID_TIME_BOX_SEQUENCE); + } + if (time <= 0) { + throw new DTClientErrorException(ClientErrorCode.INVALID_TIME_BOX_TIME); + } + + if (!boxType.isAvailable(stance)) { + throw new DTClientErrorException(ClientErrorCode.INVALID_TIME_BOX_STANCE); + } + } +} diff --git a/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxes.java b/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxes.java new file mode 100644 index 00000000..e0431e3b --- /dev/null +++ b/src/main/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxes.java @@ -0,0 +1,22 @@ +package com.debatetimer.domain.parliamentary; + +import java.util.Comparator; +import java.util.List; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +public class ParliamentaryTimeBoxes { + + private static final Comparator TIME_BOX_COMPARATOR = Comparator + .comparing(ParliamentaryTimeBox::getSequence); + + private final List timeBoxes; + + public ParliamentaryTimeBoxes(List timeBoxes) { + this.timeBoxes = timeBoxes.stream() + .sorted(TIME_BOX_COMPARATOR) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/member/MemberCreateRequest.java b/src/main/java/com/debatetimer/dto/member/MemberCreateRequest.java new file mode 100644 index 00000000..561246e1 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/member/MemberCreateRequest.java @@ -0,0 +1,11 @@ +package com.debatetimer.dto.member; + +import com.debatetimer.domain.member.Member; +import jakarta.validation.constraints.NotBlank; + +public record MemberCreateRequest(@NotBlank String nickname) { + + public Member toMember() { + return new Member(nickname); + } +} diff --git a/src/main/java/com/debatetimer/dto/member/MemberCreateResponse.java b/src/main/java/com/debatetimer/dto/member/MemberCreateResponse.java new file mode 100644 index 00000000..7a4557ef --- /dev/null +++ b/src/main/java/com/debatetimer/dto/member/MemberCreateResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.member; + +import com.debatetimer.domain.member.Member; + +public record MemberCreateResponse(long id, String nickname) { + + public MemberCreateResponse(Member member) { + this(member.getId(), member.getNickname()); + } +} diff --git a/src/main/java/com/debatetimer/dto/member/TableResponse.java b/src/main/java/com/debatetimer/dto/member/TableResponse.java new file mode 100644 index 00000000..769d0ace --- /dev/null +++ b/src/main/java/com/debatetimer/dto/member/TableResponse.java @@ -0,0 +1,15 @@ +package com.debatetimer.dto.member; + +import com.debatetimer.domain.parliamentary.ParliamentaryTable; + +public record TableResponse(long id, String name, TableType type, int duration) { + + public TableResponse(ParliamentaryTable parliamentaryTable) { + this( + parliamentaryTable.getId(), + parliamentaryTable.getName(), + TableType.PARLIAMENTARY, + parliamentaryTable.getDuration() + ); + } +} diff --git a/src/main/java/com/debatetimer/dto/member/TableResponses.java b/src/main/java/com/debatetimer/dto/member/TableResponses.java new file mode 100644 index 00000000..398ad724 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/member/TableResponses.java @@ -0,0 +1,17 @@ +package com.debatetimer.dto.member; + +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import java.util.List; + +public record TableResponses(List tables) { + + public static TableResponses from(List parliamentaryTables) { + return new TableResponses(toTableResponses(parliamentaryTables)); + } + + private static List toTableResponses(List parliamentaryTables) { + return parliamentaryTables.stream() + .map(TableResponse::new) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/member/TableType.java b/src/main/java/com/debatetimer/dto/member/TableType.java new file mode 100644 index 00000000..4d634420 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/member/TableType.java @@ -0,0 +1,7 @@ +package com.debatetimer.dto.member; + +public enum TableType { + + PARLIAMENTARY, + ; +} diff --git a/src/main/java/com/debatetimer/dto/parliamentary/request/ParliamentaryTableCreateRequest.java b/src/main/java/com/debatetimer/dto/parliamentary/request/ParliamentaryTableCreateRequest.java new file mode 100644 index 00000000..28bd6722 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/parliamentary/request/ParliamentaryTableCreateRequest.java @@ -0,0 +1,27 @@ +package com.debatetimer.dto.parliamentary.request; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBoxes; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public record ParliamentaryTableCreateRequest(TableInfoCreateRequest info, List table) { + + public ParliamentaryTable toTable(Member member) { + return info.toTable(member, sumOfTime()); + } + + private int sumOfTime() { + return table.stream() + .mapToInt(TimeBoxCreateRequest::time) + .sum(); + } + + public ParliamentaryTimeBoxes toTimeBoxes(ParliamentaryTable parliamentaryTable) { + return IntStream.range(0, table.size()) + .mapToObj(i -> table.get(i).toTimeBox(parliamentaryTable, i + 1)) + .collect(Collectors.collectingAndThen(Collectors.toList(), ParliamentaryTimeBoxes::new)); + } +} diff --git a/src/main/java/com/debatetimer/dto/parliamentary/request/TableInfoCreateRequest.java b/src/main/java/com/debatetimer/dto/parliamentary/request/TableInfoCreateRequest.java new file mode 100644 index 00000000..dceed3df --- /dev/null +++ b/src/main/java/com/debatetimer/dto/parliamentary/request/TableInfoCreateRequest.java @@ -0,0 +1,19 @@ +package com.debatetimer.dto.parliamentary.request; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record TableInfoCreateRequest( + @NotBlank + String name, + + @NotNull + String agenda +) { + + public ParliamentaryTable toTable(Member member, int duration) { + return new ParliamentaryTable(member, name, agenda, duration); + } +} diff --git a/src/main/java/com/debatetimer/dto/parliamentary/request/TimeBoxCreateRequest.java b/src/main/java/com/debatetimer/dto/parliamentary/request/TimeBoxCreateRequest.java new file mode 100644 index 00000000..9204113c --- /dev/null +++ b/src/main/java/com/debatetimer/dto/parliamentary/request/TimeBoxCreateRequest.java @@ -0,0 +1,26 @@ +package com.debatetimer.dto.parliamentary.request; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public record TimeBoxCreateRequest( + @NotBlank + Stance stance, + + @NotBlank + BoxType type, + + @Positive + int time, + + Integer speakerNumber +) { + + public ParliamentaryTimeBox toTimeBox(ParliamentaryTable parliamentaryTable, int sequence) { + return new ParliamentaryTimeBox(parliamentaryTable, sequence, stance, type, time, speakerNumber); + } +} diff --git a/src/main/java/com/debatetimer/dto/parliamentary/response/ParliamentaryTableResponse.java b/src/main/java/com/debatetimer/dto/parliamentary/response/ParliamentaryTableResponse.java new file mode 100644 index 00000000..c75a0a92 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/parliamentary/response/ParliamentaryTableResponse.java @@ -0,0 +1,26 @@ +package com.debatetimer.dto.parliamentary.response; + +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBoxes; +import java.util.List; + +public record ParliamentaryTableResponse(long id, TableInfoResponse info, List table) { + + public ParliamentaryTableResponse( + ParliamentaryTable parliamentaryTable, + ParliamentaryTimeBoxes parliamentaryTimeBoxes + ) { + this( + parliamentaryTable.getId(), + new TableInfoResponse(parliamentaryTable), + toTimeBoxResponses(parliamentaryTimeBoxes) + ); + } + + private static List toTimeBoxResponses(ParliamentaryTimeBoxes parliamentaryTimeBoxes) { + return parliamentaryTimeBoxes.getTimeBoxes() + .stream() + .map(TimeBoxResponse::new) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/parliamentary/response/TableInfoResponse.java b/src/main/java/com/debatetimer/dto/parliamentary/response/TableInfoResponse.java new file mode 100644 index 00000000..e1369eb5 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/parliamentary/response/TableInfoResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.parliamentary.response; + +import com.debatetimer.domain.parliamentary.ParliamentaryTable; + +public record TableInfoResponse(String name, String agenda) { + + public TableInfoResponse(ParliamentaryTable parliamentaryTable) { + this(parliamentaryTable.getName(), parliamentaryTable.getAgenda()); + } +} diff --git a/src/main/java/com/debatetimer/dto/parliamentary/response/TimeBoxResponse.java b/src/main/java/com/debatetimer/dto/parliamentary/response/TimeBoxResponse.java new file mode 100644 index 00000000..2eb8dd29 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/parliamentary/response/TimeBoxResponse.java @@ -0,0 +1,16 @@ +package com.debatetimer.dto.parliamentary.response; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; + +public record TimeBoxResponse(Stance stance, BoxType type, int time, Integer speakerNumber) { + + public TimeBoxResponse(ParliamentaryTimeBox parliamentaryTimeBox) { + this(parliamentaryTimeBox.getStance(), + parliamentaryTimeBox.getType(), + parliamentaryTimeBox.getTime(), + parliamentaryTimeBox.getSpeaker() + ); + } +} diff --git a/src/main/java/com/debatetimer/exception/ErrorResponse.java b/src/main/java/com/debatetimer/exception/ErrorResponse.java new file mode 100644 index 00000000..16401bcd --- /dev/null +++ b/src/main/java/com/debatetimer/exception/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.debatetimer.exception; + +import jakarta.validation.constraints.NotBlank; + +public record ErrorResponse(@NotBlank String message) { + +} diff --git a/src/main/java/com/debatetimer/exception/custom/DTClientErrorException.java b/src/main/java/com/debatetimer/exception/custom/DTClientErrorException.java new file mode 100644 index 00000000..bd2b0e75 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/custom/DTClientErrorException.java @@ -0,0 +1,10 @@ +package com.debatetimer.exception.custom; + +import com.debatetimer.exception.errorcode.ClientErrorCode; + +public class DTClientErrorException extends DTException { + + public DTClientErrorException(ClientErrorCode clientErrorCode) { + super(clientErrorCode.getMessage(), clientErrorCode.getStatus()); + } +} diff --git a/src/main/java/com/debatetimer/exception/custom/DTException.java b/src/main/java/com/debatetimer/exception/custom/DTException.java new file mode 100644 index 00000000..51157630 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/custom/DTException.java @@ -0,0 +1,15 @@ +package com.debatetimer.exception.custom; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public abstract class DTException extends RuntimeException { + + private final HttpStatus httpStatus; + + protected DTException(String message, HttpStatus httpStatus) { + super(message); + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/debatetimer/exception/custom/DTServerErrorException.java b/src/main/java/com/debatetimer/exception/custom/DTServerErrorException.java new file mode 100644 index 00000000..1e26c354 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/custom/DTServerErrorException.java @@ -0,0 +1,10 @@ +package com.debatetimer.exception.custom; + +import com.debatetimer.exception.errorcode.ServerErrorCode; + +public class DTServerErrorException extends DTException { + + public DTServerErrorException(ServerErrorCode serverErrorCode) { + super(serverErrorCode.getMessage(), serverErrorCode.getStatus()); + } +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java new file mode 100644 index 00000000..bdaf2be2 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -0,0 +1,53 @@ +package com.debatetimer.exception.errorcode; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ClientErrorCode implements ErrorCode { + + INVALID_MEMBER_NICKNAME_LENGTH( + HttpStatus.BAD_REQUEST, + "닉네임은 1자 이상 %d자 이하여야 합니다".formatted(Member.NICKNAME_MAX_LENGTH) + ), + INVALID_MEMBER_NICKNAME_FORM(HttpStatus.BAD_REQUEST, "닉네임은 영문/한글만 가능합니다"), + + INVALID_TABLE_NAME_LENGTH( + HttpStatus.BAD_REQUEST, + "테이블 이름은 1자 이상 %d자 이하여야 합니다".formatted(ParliamentaryTable.NAME_MAX_LENGTH) + ), + INVALID_TABLE_NAME_FORM( + HttpStatus.BAD_REQUEST, + "테이블 이름은 영문/한글/숫자/띄어쓰기만 가능합니다" + ), + INVALID_TABLE_TIME(HttpStatus.BAD_REQUEST, "시간은 양수만 가능합니다"), + + INVALID_TIME_BOX_SEQUENCE(HttpStatus.BAD_REQUEST, "순서는 양수만 가능합니다"), + INVALID_TIME_BOX_TIME(HttpStatus.BAD_REQUEST, "시간은 양수만 가능합니다"), + INVALID_TIME_BOX_STANCE(HttpStatus.BAD_REQUEST, "타임박스 유형과 일치하지 않는 입장입니다."), + + FIELD_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."), + URL_PARAMETER_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."), + METHOD_ARGUMENT_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "입력한 값의 타입이 잘못되었습니다."), + NO_RESOURCE_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + METHOD_NOT_SUPPORTED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메서드입니다."), + MEDIA_TYPE_NOT_SUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "허용되지 않은 미디어 타입입니다."), + ALREADY_DISCONNECTED(HttpStatus.BAD_REQUEST, "이미 클라이언트에서 요청이 종료되었습니다."), + + TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 테이블을 찾을 수 없습니다."), + NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."), + UNAUTHORIZED_MEMBER(HttpStatus.UNAUTHORIZED, "접근 권한이 없습니다"), + + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원이 존재하지 않습니다"), + ; + + private final HttpStatus status; + private final String message; + + ClientErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ErrorCode.java new file mode 100644 index 00000000..fc9fe3f4 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/errorcode/ErrorCode.java @@ -0,0 +1,10 @@ +package com.debatetimer.exception.errorcode; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + HttpStatus getStatus(); + + String getMessage(); +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java new file mode 100644 index 00000000..c3ebba80 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java @@ -0,0 +1,19 @@ +package com.debatetimer.exception.errorcode; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ServerErrorCode implements ErrorCode { + + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다. 관리자에게 문의하세요."), + EXCEL_EXPORT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "엑셀 변환 과정에서 오류가 발생하였습니다"); + + private final HttpStatus status; + private final String message; + + ServerErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..a0d381c3 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,99 @@ +package com.debatetimer.exception.handler; + +import com.debatetimer.exception.ErrorResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.exception.errorcode.ErrorCode; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.apache.catalina.connector.ClientAbortException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BindException.class) + public ResponseEntity handleBindingException(BindException exception) { + log.warn("message: {}", exception.getMessage()); + return toResponse(ClientErrorCode.FIELD_ERROR); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException exception) { + log.warn("message: {}", exception.getMessage()); + return toResponse(ClientErrorCode.URL_PARAMETER_ERROR); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException exception) { + log.warn("message: {}", exception.getMessage()); + return toResponse(ClientErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH); + } + + @ExceptionHandler(ClientAbortException.class) + public ResponseEntity handleClientAbortException(ClientAbortException exception) { + log.warn("message: {}", exception.getMessage()); + return toResponse(ClientErrorCode.ALREADY_DISCONNECTED); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException exception + ) { + log.warn("message: {}", exception.getMessage()); + return toResponse(ClientErrorCode.METHOD_NOT_SUPPORTED); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleHttpMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException exception + ) { + log.warn("message: {}", exception.getMessage()); + return toResponse(ClientErrorCode.MEDIA_TYPE_NOT_SUPPORTED); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException exception) { + return toResponse(ClientErrorCode.NO_RESOURCE_FOUND); + } + + @ExceptionHandler(DTClientErrorException.class) + public ResponseEntity handleClientException(DTClientErrorException exception) { + log.warn("message: {}", exception.getMessage()); + return toResponse(exception.getHttpStatus(), exception.getMessage()); + } + + @ExceptionHandler(DTServerErrorException.class) + public ResponseEntity handleServerException(DTServerErrorException exception) { + log.warn("message: {}", exception.getMessage()); + return toResponse(exception.getHttpStatus(), exception.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception exception) { + log.error("exception: {}", exception); + return toResponse(ServerErrorCode.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity toResponse(ErrorCode errorCode) { + return toResponse(errorCode.getStatus(), errorCode.getMessage()); + } + + private ResponseEntity toResponse(HttpStatus httpStatus, String message) { + ErrorResponse errorResponse = new ErrorResponse(message); + return ResponseEntity.status(httpStatus) + .body(errorResponse); + } +} diff --git a/src/main/java/com/debatetimer/repository/member/MemberRepository.java b/src/main/java/com/debatetimer/repository/member/MemberRepository.java new file mode 100644 index 00000000..de78ccff --- /dev/null +++ b/src/main/java/com/debatetimer/repository/member/MemberRepository.java @@ -0,0 +1,21 @@ +package com.debatetimer.repository.member; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface MemberRepository extends Repository { + + Member save(Member member); + + Optional findById(long id); + + default Member getById(long id) { + return findById(id) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.MEMBER_NOT_FOUND)); + } + + Optional findByNickname(String nickname); +} diff --git a/src/main/java/com/debatetimer/repository/parliamentary/ParliamentaryTableRepository.java b/src/main/java/com/debatetimer/repository/parliamentary/ParliamentaryTableRepository.java new file mode 100644 index 00000000..f60ca55b --- /dev/null +++ b/src/main/java/com/debatetimer/repository/parliamentary/ParliamentaryTableRepository.java @@ -0,0 +1,25 @@ +package com.debatetimer.repository.parliamentary; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.List; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface ParliamentaryTableRepository extends Repository { + + ParliamentaryTable save(ParliamentaryTable parliamentaryTable); + + Optional findById(long id); + + default ParliamentaryTable getById(long tableId) { + return findById(tableId) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND)); + } + + List findAllByMember(Member member); + + void delete(ParliamentaryTable table); +} diff --git a/src/main/java/com/debatetimer/repository/parliamentary/ParliamentaryTimeBoxRepository.java b/src/main/java/com/debatetimer/repository/parliamentary/ParliamentaryTimeBoxRepository.java new file mode 100644 index 00000000..c641c242 --- /dev/null +++ b/src/main/java/com/debatetimer/repository/parliamentary/ParliamentaryTimeBoxRepository.java @@ -0,0 +1,34 @@ +package com.debatetimer.repository.parliamentary; + +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBoxes; +import java.util.List; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.transaction.annotation.Transactional; + +public interface ParliamentaryTimeBoxRepository extends Repository { + + ParliamentaryTimeBox save(ParliamentaryTimeBox timeBox); + + @Transactional + default List saveAll(List timeBoxes) { + return timeBoxes.stream() + .map(this::save) + .toList(); + } + + List findAllByParliamentaryTable(ParliamentaryTable table); + + default ParliamentaryTimeBoxes findTableTimeBoxes(ParliamentaryTable table) { + List timeBoxes = findAllByParliamentaryTable(table); + return new ParliamentaryTimeBoxes(timeBoxes); + } + + @Query("DELETE FROM ParliamentaryTimeBox ptb WHERE ptb IN :timeBoxes") + @Modifying(clearAutomatically = true) + @Transactional + void deleteAll(List timeBoxes); +} diff --git a/src/main/java/com/debatetimer/service/member/MemberService.java b/src/main/java/com/debatetimer/service/member/MemberService.java new file mode 100644 index 00000000..2273efb2 --- /dev/null +++ b/src/main/java/com/debatetimer/service/member/MemberService.java @@ -0,0 +1,36 @@ +package com.debatetimer.service.member; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.dto.member.MemberCreateRequest; +import com.debatetimer.dto.member.MemberCreateResponse; +import com.debatetimer.dto.member.TableResponses; +import com.debatetimer.repository.member.MemberRepository; +import com.debatetimer.repository.parliamentary.ParliamentaryTableRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final ParliamentaryTableRepository parliamentaryTableRepository; + + @Transactional(readOnly = true) + public TableResponses getTables(Long memberId) { + Member member = memberRepository.getById(memberId); + List parliamentaryTable = parliamentaryTableRepository.findAllByMember(member); + return TableResponses.from(parliamentaryTable); + } + + @Transactional + public MemberCreateResponse createMember(MemberCreateRequest request) { + // TODO OAuth 로직 들어오면서 수정 예정 + Member member = memberRepository.findByNickname(request.nickname()) + .orElse(memberRepository.save(request.toMember())); + return new MemberCreateResponse(member); + } +} diff --git a/src/main/java/com/debatetimer/service/parliamentary/ParliamentaryService.java b/src/main/java/com/debatetimer/service/parliamentary/ParliamentaryService.java new file mode 100644 index 00000000..1aad472a --- /dev/null +++ b/src/main/java/com/debatetimer/service/parliamentary/ParliamentaryService.java @@ -0,0 +1,85 @@ +package com.debatetimer.service.parliamentary; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBoxes; +import com.debatetimer.dto.parliamentary.request.ParliamentaryTableCreateRequest; +import com.debatetimer.dto.parliamentary.response.ParliamentaryTableResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.parliamentary.ParliamentaryTableRepository; +import com.debatetimer.repository.parliamentary.ParliamentaryTimeBoxRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ParliamentaryService { + + private final ParliamentaryTableRepository tableRepository; + private final ParliamentaryTimeBoxRepository timeBoxRepository; + + @Transactional + public ParliamentaryTableResponse save(ParliamentaryTableCreateRequest tableCreateRequest, Member member) { + ParliamentaryTable table = tableCreateRequest.toTable(member); + ParliamentaryTable savedTable = tableRepository.save(table); + + ParliamentaryTimeBoxes savedTimeBoxes = saveTimeBoxes(tableCreateRequest, savedTable); + return new ParliamentaryTableResponse(savedTable, savedTimeBoxes); + } + + @Transactional(readOnly = true) + public ParliamentaryTableResponse findTable(long tableId, Member member) { + ParliamentaryTable table = getOwnerTable(tableId, member.getId()); + ParliamentaryTimeBoxes timeBoxes = timeBoxRepository.findTableTimeBoxes(table); + return new ParliamentaryTableResponse(table, timeBoxes); + } + + @Transactional + public ParliamentaryTableResponse updateTable( + ParliamentaryTableCreateRequest tableCreateRequest, + long tableId, + Member member + ) { + ParliamentaryTable existingTable = getOwnerTable(tableId, member.getId()); + ParliamentaryTable renewedTable = tableCreateRequest.toTable(member); + existingTable.update(renewedTable); + + ParliamentaryTimeBoxes timeBoxes = timeBoxRepository.findTableTimeBoxes(existingTable); + timeBoxRepository.deleteAll(timeBoxes.getTimeBoxes()); + ParliamentaryTimeBoxes savedTimeBoxes = saveTimeBoxes(tableCreateRequest, existingTable); + return new ParliamentaryTableResponse(existingTable, savedTimeBoxes); + } + + @Transactional + public void deleteTable(Long tableId, Member member) { + ParliamentaryTable table = getOwnerTable(tableId, member.getId()); + ParliamentaryTimeBoxes timeBoxes = timeBoxRepository.findTableTimeBoxes(table); + timeBoxRepository.deleteAll(timeBoxes.getTimeBoxes()); + tableRepository.delete(table); + } + + private ParliamentaryTimeBoxes saveTimeBoxes( + ParliamentaryTableCreateRequest tableCreateRequest, + ParliamentaryTable table + ) { + ParliamentaryTimeBoxes timeBoxes = tableCreateRequest.toTimeBoxes(table); + List savedTimeBoxes = timeBoxRepository.saveAll(timeBoxes.getTimeBoxes()); + return new ParliamentaryTimeBoxes(savedTimeBoxes); + } + + private ParliamentaryTable getOwnerTable(long tableId, long memberId) { + ParliamentaryTable foundTable = tableRepository.getById(tableId); + validateOwn(foundTable, memberId); + return foundTable; + } + + private void validateOwn(ParliamentaryTable table, long memberId) { + if (!table.isOwner(memberId)) { + throw new DTClientErrorException(ClientErrorCode.NOT_TABLE_OWNER); + } + } +} diff --git a/src/main/java/com/debatetimer/view/exporter/BoxTypeView.java b/src/main/java/com/debatetimer/view/exporter/BoxTypeView.java new file mode 100644 index 00000000..e42bcea8 --- /dev/null +++ b/src/main/java/com/debatetimer/view/exporter/BoxTypeView.java @@ -0,0 +1,30 @@ +package com.debatetimer.view.exporter; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BoxTypeView { + OPENING_VIEW(BoxType.OPENING, "입론"), + REBUTTAL_VIEW(BoxType.REBUTTAL, "반론"), + CROSS(BoxType.CROSS, "교차 질의"), + CLOSING(BoxType.CLOSING, "최종 발언"), + TIME_OUT(BoxType.TIME_OUT, "작전 시간"), + ; + + private final BoxType boxType; + private final String viewMessage; + + public static String mapView(BoxType target) { + return Stream.of(values()) + .filter(value -> value.boxType == target) + .findAny() + .orElseThrow(() -> new DTServerErrorException(ServerErrorCode.EXCEL_EXPORT_ERROR)) + .viewMessage; + } +} diff --git a/src/main/java/com/debatetimer/view/exporter/ParliamentaryTableExcelExporter.java b/src/main/java/com/debatetimer/view/exporter/ParliamentaryTableExcelExporter.java new file mode 100644 index 00000000..20a27ccc --- /dev/null +++ b/src/main/java/com/debatetimer/view/exporter/ParliamentaryTableExcelExporter.java @@ -0,0 +1,189 @@ +package com.debatetimer.view.exporter; + +import com.debatetimer.domain.Stance; +import com.debatetimer.dto.parliamentary.response.ParliamentaryTableResponse; +import com.debatetimer.dto.parliamentary.response.TableInfoResponse; +import com.debatetimer.dto.parliamentary.response.TimeBoxResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ParliamentaryTableExcelExporter { + + private static Font NORMAL; + private static Font BOLD; + private static Font BOLD_AND_WHITE; + + private static CellStyle HEADER_STYLE; + private static CellStyle HEADER_CELL_STYLE; + private static CellStyle PROS_HEADER_STYLE; + private static CellStyle CONS_HEADER_STYLE; + private static CellStyle PROS_STYLE; + private static CellStyle CONS_STYLE; + private static CellStyle NEUTRAL_STYLE; + + private static final String NAME_HEADER = "테이블 이름"; + private static final String TYPE_HEADER = "형식"; + private static final String PARLIAMENTARY_HEADER_BODY = "의회식 토론"; + private static final String AGENDA_HEADER = "토론 주제"; + private static final String PROS_HEADER = "찬성"; + private static final String CONS_HEADER = "반대"; + + private static final int PROS_COLUMN_NUMBER = 0; + private static final int CONS_COLUMN_NUMBER = 1; + private static final int NAME_HEADER_ROW_NUMBER = 1; + private static final int TYPE_HEADER_ROW_NUMBER = 2; + private static final int AGENDA_HEADER_ROW_NUMBER = 3; + private static final int TABLE_HEADER_ROW_NUMBER = 5; + private static final int TIME_BOX_FIRST_ROW_NUMBER = 6; + private static final int END_COLUMN_NUMBER = 3; + private static final int WIDTH_SIZE = 10000; + + private final ParliamentaryTableExportMessageResolver messageResolver; + + private static Font createFont(Workbook workbook, boolean bold, IndexedColors fontColor) { + Font font = workbook.createFont(); + font.setColor(fontColor.getIndex()); + font.setBold(bold); + return font; + } + + private static CellStyle createGroundColor( + Workbook workBook, + IndexedColors color, + Font cellFont, + HorizontalAlignment align + ) { + CellStyle style = workBook.createCellStyle(); + style.setFillForegroundColor(color.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setFont(cellFont); + style.setAlignment(align); + style.setVerticalAlignment(VerticalAlignment.CENTER); + return style; + } + + private static void initializeFont(Workbook workbook) { + NORMAL = createFont(workbook, false, IndexedColors.BLACK); + BOLD = createFont(workbook, true, IndexedColors.BLACK); + BOLD_AND_WHITE = createFont(workbook, true, IndexedColors.WHITE); + } + + private static void initializeStyle(Workbook workbook) { + HEADER_STYLE = createGroundColor(workbook, IndexedColors.LIGHT_YELLOW, BOLD, HorizontalAlignment.LEFT); + HEADER_CELL_STYLE = createGroundColor(workbook, IndexedColors.LIGHT_YELLOW, NORMAL, HorizontalAlignment.LEFT); + PROS_HEADER_STYLE = createGroundColor(workbook, IndexedColors.CORNFLOWER_BLUE, BOLD, + HorizontalAlignment.CENTER); + CONS_HEADER_STYLE = createGroundColor(workbook, IndexedColors.CORAL, BOLD, HorizontalAlignment.CENTER); + PROS_STYLE = createGroundColor(workbook, IndexedColors.LIGHT_CORNFLOWER_BLUE, NORMAL, + HorizontalAlignment.CENTER); + CONS_STYLE = createGroundColor(workbook, IndexedColors.ROSE, NORMAL, HorizontalAlignment.CENTER); + NEUTRAL_STYLE = createGroundColor(workbook, IndexedColors.GREY_50_PERCENT, BOLD_AND_WHITE, + HorizontalAlignment.CENTER); + } + + public Workbook export(ParliamentaryTableResponse parliamentaryTableResponse) { + TableInfoResponse tableInfo = parliamentaryTableResponse.info(); + List timeBoxes = parliamentaryTableResponse.table(); + + Workbook workbook = new XSSFWorkbook(); + Sheet sheet = workbook.createSheet(tableInfo.name()); + initializeFont(workbook); + initializeStyle(workbook); + + createHeader(sheet, NAME_HEADER_ROW_NUMBER, NAME_HEADER, tableInfo.name()); + createHeader(sheet, TYPE_HEADER_ROW_NUMBER, TYPE_HEADER, PARLIAMENTARY_HEADER_BODY); + createHeader(sheet, AGENDA_HEADER_ROW_NUMBER, AGENDA_HEADER, tableInfo.agenda()); + + createTableHeader(sheet); + createTimeBoxRows(timeBoxes, sheet); + setColumnWidth(sheet); + return workbook; + } + + private void createHeader( + Sheet sheet, + int rowNumber, + String header, + String headerBody + ) { + Row row = sheet.createRow(rowNumber); + createCell(row, 0, header, HEADER_STYLE); + createCell(row, 1, headerBody, HEADER_CELL_STYLE); + } + + private void createTableHeader(Sheet sheet) { + Row row = sheet.createRow(TABLE_HEADER_ROW_NUMBER); + createCell(row, PROS_COLUMN_NUMBER, PROS_HEADER, PROS_HEADER_STYLE); + createCell(row, CONS_COLUMN_NUMBER, CONS_HEADER, CONS_HEADER_STYLE); + } + + private void createCell(Row row, int index, String value, CellStyle style) { + Cell cell = row.createCell(index); + cell.setCellStyle(style); + cell.setCellValue(value); + } + + private void setColumnWidth(Sheet sheet) { + for (int i = 0; i < END_COLUMN_NUMBER; i++) { + sheet.setColumnWidth(i, WIDTH_SIZE); + } + } + + private void createTimeBoxRows(List timeBoxes, Sheet sheet) { + for (int i = 0; i < timeBoxes.size(); i++) { + createTimeBoxRow(sheet, i + TIME_BOX_FIRST_ROW_NUMBER, timeBoxes.get(i)); + } + } + + private void createTimeBoxRow(Sheet sheet, int rowNumber, TimeBoxResponse timeBox) { + Row row = sheet.createRow(rowNumber); + String timeBoxMessage = messageResolver.resolveBoxMessage(timeBox); + Stance stance = timeBox.stance(); + createRowByStance(sheet, row, stance, rowNumber, timeBoxMessage); + } + + private void createRowByStance(Sheet sheet, Row row, Stance stance, int index, String message) { + switch (stance) { + case NEUTRAL: + Cell neturalCell = row.createCell(0); + neturalCell.setCellStyle(NEUTRAL_STYLE); + neturalCell.setCellValue(message); + sheet.addMergedRegion(new CellRangeAddress(index, index, 0, 1)); // A1:B1 셀 병합 + break; + + case PROS: + setProsAndConsCellstyle(row); + Cell prosCell = row.getCell(PROS_COLUMN_NUMBER); + prosCell.setCellValue(message); + break; + + case CONS: + setProsAndConsCellstyle(row); + Cell consCell = row.getCell(CONS_COLUMN_NUMBER); + consCell.setCellValue(message); + break; + } + } + + private void setProsAndConsCellstyle(Row row) { + Cell prosCell = row.createCell(PROS_COLUMN_NUMBER); + prosCell.setCellStyle(PROS_STYLE); + Cell consCell = row.createCell(CONS_COLUMN_NUMBER); + consCell.setCellStyle(CONS_STYLE); + } +} diff --git a/src/main/java/com/debatetimer/view/exporter/ParliamentaryTableExportMessageResolver.java b/src/main/java/com/debatetimer/view/exporter/ParliamentaryTableExportMessageResolver.java new file mode 100644 index 00000000..b4a069d5 --- /dev/null +++ b/src/main/java/com/debatetimer/view/exporter/ParliamentaryTableExportMessageResolver.java @@ -0,0 +1,51 @@ +package com.debatetimer.view.exporter; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.dto.parliamentary.response.TimeBoxResponse; +import org.springframework.stereotype.Component; + +@Component +public class ParliamentaryTableExportMessageResolver { + + private static final String SPEAKER_SUFFIX = "번 토론자"; + private static final String MINUTES_MESSAGE = "분"; + private static final String SECOND_MESSAGE = "초"; + private static final String TIME_MESSAGE_PREFIX = "("; + private static final String TIME_MESSAGE_SUFFIX = ")"; + private static final String MESSAGE_DELIMITER = "/"; + private static final String SPACE = " "; + + public String resolveBoxMessage(TimeBoxResponse timeBox) { + String defaultMessage = resolveDefaultMessage(timeBox); + BoxType type = timeBox.type(); + if (type == BoxType.TIME_OUT) { + return defaultMessage; + } + return defaultMessage + + MESSAGE_DELIMITER + + resolveSpeakerMessage(timeBox.speakerNumber()); + } + + private String resolveDefaultMessage(TimeBoxResponse timeBox) { + BoxType boxType = timeBox.type(); + return BoxTypeView.mapView(boxType) + + resolveTimeMessage(timeBox.time()); + } + + private String resolveTimeMessage(int totalSecond) { + int minutes = totalSecond / 60; + int second = totalSecond % 60; + String message = minutes + MINUTES_MESSAGE; + + if (second != 0) { + message += SPACE + second + SECOND_MESSAGE; + } + return TIME_MESSAGE_PREFIX + + message + + TIME_MESSAGE_SUFFIX; + } + + private String resolveSpeakerMessage(int speakerNumber) { + return speakerNumber + SPEAKER_SUFFIX; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..01065082 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,19 @@ +spring: + config: + import: dev-secret.yml + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${secret.datasource.url}:${secret.datasource.port}/${secret.datasource.database}?useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true&autoReconnect=true&serverTimezone=Asia/Seoul&useLegacyDatetimeCode=false + username: ${secret.datasource.username} + password: ${secret.datasource.password} + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: update + defer-datasource-initialization: true + +cors: + origin: ${secret.cors.origin} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..751b8a26 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,21 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:database + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: true + +cors: + origin: * diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..1dce30a9 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,19 @@ +spring: + config: + import: prod-secret.yml + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${secret.datasource.url}:${secret.datasource.port}/${secret.datasource.database}?useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true&autoReconnect=true&serverTimezone=Asia/Seoul&useLegacyDatetimeCode=false + username: ${secret.datasource.username} + password: ${secret.datasource.password} + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: validate + defer-datasource-initialization: true + +cors: + origin: ${secret.cors.origin} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..eb1d81ff --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,12 @@ +spring: + profiles: + default: local + +springdoc: + swagger-ui: + path: /docs/swagger + url: /docs/openapi3.yaml + disable-swagger-default-url: true + filter: true + persist-authorization: true + display-request-duration: true diff --git a/src/main/resources/dev-secret.yml b/src/main/resources/dev-secret.yml new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/prod-secret.yml b/src/main/resources/prod-secret.yml new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/static/docs/.gitkeep b/src/main/resources/static/docs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/java/com/debatetimer/DataBaseCleaner.java b/src/test/java/com/debatetimer/DataBaseCleaner.java new file mode 100644 index 00000000..3474ee83 --- /dev/null +++ b/src/test/java/com/debatetimer/DataBaseCleaner.java @@ -0,0 +1,47 @@ +package com.debatetimer; + +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.support.TransactionTemplate; + +public class DataBaseCleaner implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + ApplicationContext context = SpringExtension.getApplicationContext(extensionContext); + cleanup(context); + } + + private void cleanup(ApplicationContext context) { + EntityManager em = context.getBean(EntityManager.class); + TransactionTemplate transactionTemplate = context.getBean(TransactionTemplate.class); + + transactionTemplate.execute(action -> { + em.clear(); + truncateTables(em); + return null; + }); + } + + private void truncateTables(EntityManager em) { + em.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : findTableNames(em)) { + em.createNativeQuery("TRUNCATE TABLE %s RESTART IDENTITY".formatted(tableName)).executeUpdate(); + } + em.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } + + @SuppressWarnings("unchecked") + private List findTableNames(EntityManager em) { + String tableNameSelectQuery = """ + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = 'PUBLIC' + """; + return em.createNativeQuery(tableNameSelectQuery).getResultList(); + } +} diff --git a/src/test/java/com/debatetimer/DebateTimerApplicationTest.java b/src/test/java/com/debatetimer/DebateTimerApplicationTest.java new file mode 100644 index 00000000..aad9bc29 --- /dev/null +++ b/src/test/java/com/debatetimer/DebateTimerApplicationTest.java @@ -0,0 +1,13 @@ +package com.debatetimer; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DebateTimerApplicationTest { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/debatetimer/controller/BaseControllerTest.java b/src/test/java/com/debatetimer/controller/BaseControllerTest.java new file mode 100644 index 00000000..0885791e --- /dev/null +++ b/src/test/java/com/debatetimer/controller/BaseControllerTest.java @@ -0,0 +1,58 @@ +package com.debatetimer.controller; + +import com.debatetimer.DataBaseCleaner; +import com.debatetimer.fixture.MemberGenerator; +import com.debatetimer.fixture.ParliamentaryTableGenerator; +import com.debatetimer.fixture.ParliamentaryTimeBoxGenerator; +import com.debatetimer.repository.member.MemberRepository; +import com.debatetimer.repository.parliamentary.ParliamentaryTableRepository; +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.filter.log.RequestLoggingFilter; +import io.restassured.filter.log.ResponseLoggingFilter; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.restassured.RestDocumentationFilter; + +@ExtendWith(DataBaseCleaner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseControllerTest { + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ParliamentaryTableRepository parliamentaryTableRepository; + + @Autowired + protected MemberGenerator memberGenerator; + + @Autowired + protected ParliamentaryTableGenerator tableGenerator; + + @Autowired + protected ParliamentaryTimeBoxGenerator timeBoxGenerator; + + @LocalServerPort + private int port; + + private RequestSpecification spec; + + @BeforeEach + void setEnvironment() { + RestAssured.port = port; + spec = new RequestSpecBuilder() + .addFilter(new RequestLoggingFilter()) + .addFilter(new ResponseLoggingFilter()) + .build(); + } + + protected RequestSpecification given() { + return RestAssured.given(spec); + } +} diff --git a/src/test/java/com/debatetimer/controller/BaseDocumentTest.java b/src/test/java/com/debatetimer/controller/BaseDocumentTest.java new file mode 100644 index 00000000..46c879a9 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/BaseDocumentTest.java @@ -0,0 +1,96 @@ +package com.debatetimer.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.member.MemberRepository; +import com.debatetimer.service.member.MemberService; +import com.debatetimer.service.parliamentary.ParliamentaryService; +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.filter.log.RequestLoggingFilter; +import io.restassured.filter.log.ResponseLoggingFilter; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.restassured.RestAssuredRestDocumentation; +import org.springframework.restdocs.restassured.RestAssuredRestDocumentationConfigurer; +import org.springframework.restdocs.restassured.RestDocumentationFilter; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ExtendWith({RestDocumentationExtension.class, MockitoExtension.class}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseDocumentTest { + + protected static long EXIST_MEMBER_ID = 123L; + protected static Member EXIST_MEMBER = new Member(EXIST_MEMBER_ID, "존재하는 멤버"); + + protected static RestDocumentationResponse ERROR_RESPONSE = new RestDocumentationResponse() + .responseBodyField( + fieldWithPath("message").type(STRING).description("에러 메시지") + ); + + @MockitoBean + private MemberRepository memberRepository; + + @MockitoBean + protected MemberService memberService; + + @MockitoBean + protected ParliamentaryService parliamentaryService; + + @LocalServerPort + private int port; + + private RequestSpecification spec; + + @BeforeEach + void setEnvironment(RestDocumentationContextProvider restDocumentation) { + setRestAssured(restDocumentation); + setLoginMember(); + } + + private void setRestAssured(RestDocumentationContextProvider restDocumentation) { + RestAssured.port = port; + RestAssuredRestDocumentationConfigurer webConfigurer = + RestAssuredRestDocumentation.documentationConfiguration(restDocumentation); + spec = new RequestSpecBuilder() + .addFilter(webConfigurer) + .addFilter(new RequestLoggingFilter()) + .addFilter(new ResponseLoggingFilter()) + .build(); + } + + private void setLoginMember() { + when(memberRepository.getById(EXIST_MEMBER_ID)).thenReturn(EXIST_MEMBER); + } + + protected RestDocumentationRequest request() { + return new RestDocumentationRequest(); + } + + protected RestDocumentationResponse response() { + return new RestDocumentationResponse(); + } + + protected RestDocumentationFilterBuilder document(String identifierPrefix, int status) { + return new RestDocumentationFilterBuilder(identifierPrefix, Integer.toString(status)); + } + + protected RestDocumentationFilterBuilder document(String identifierPrefix, ClientErrorCode errorCode) { + return new RestDocumentationFilterBuilder(identifierPrefix, errorCode.name()); + } + + protected RequestSpecification given(RestDocumentationFilter documentationFilter) { + return RestAssured.given(spec) + .filter(documentationFilter); + } +} diff --git a/src/test/java/com/debatetimer/controller/GlobalControllerTest.java b/src/test/java/com/debatetimer/controller/GlobalControllerTest.java new file mode 100644 index 00000000..d4347a38 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/GlobalControllerTest.java @@ -0,0 +1,44 @@ +package com.debatetimer.controller; + +import static org.hamcrest.Matchers.containsString; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; + +public class GlobalControllerTest extends BaseControllerTest { + + @Value("${cors.origin}") + private String corsOrigin; + + @Nested + class CorsConfigTest { + + @Test + void CORS_preflight에서_허용된_origin의_요청을_정상적으로_처리할_수_있다() { + String allowedMethod = "GET"; + + given() + .header("Origin", corsOrigin) + .header("Access-Control-Request-Method", allowedMethod) + .header("Access-Control-Request-Headers", "Authorization, Content-Type") + .when().options("/") + .then().statusCode(200) + .headers("Access-Control-Allow-Origin", corsOrigin) + .header("Access-Control-Allow-Methods", containsString(allowedMethod)); + } + + @Test + void CORS_preflight에서_허용되지_않은_origin의_요청을_막을_수_있다() { + String notAllowedOrigin = "https://not-allowed-origin.com"; + String allowedMethod = "GET"; + + given() + .header("Origin", notAllowedOrigin) + .header("Access-Control-Request-Method", allowedMethod) + .header("Access-Control-Request-Headers", "Authorization, Content-Type") + .when().options("/") + .then().statusCode(403); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/RestDocumentationFilterBuilder.java b/src/test/java/com/debatetimer/controller/RestDocumentationFilterBuilder.java new file mode 100644 index 00000000..91ec2a34 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/RestDocumentationFilterBuilder.java @@ -0,0 +1,67 @@ +package com.debatetimer.controller; + +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; + +import com.epages.restdocs.apispec.ResourceSnippetParametersBuilder; +import java.util.ArrayList; +import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; +import org.springframework.restdocs.operation.preprocess.Preprocessors; +import org.springframework.restdocs.restassured.RestDocumentationFilter; +import org.springframework.restdocs.snippet.Snippet; + +public class RestDocumentationFilterBuilder { + + private static final String IDENTIFIER_DELIMITER = "/"; + private static final OperationRequestPreprocessor REQUEST_PREPROCESSOR = Preprocessors.preprocessRequest( + Preprocessors.prettyPrint(), + Preprocessors.modifyHeaders() + .remove(HttpHeaders.HOST) + .remove(HttpHeaders.CONTENT_LENGTH) + ); + private static final OperationResponsePreprocessor RESPONSE_PREPROCESSOR = Preprocessors.preprocessResponse( + Preprocessors.prettyPrint(), + Preprocessors.modifyHeaders() + .remove(HttpHeaders.TRANSFER_ENCODING) + .remove(HttpHeaders.DATE) + .remove(HttpHeaders.CONNECTION) + .remove(HttpHeaders.CONTENT_LENGTH) + ); + + private final String identifier; + private final List snippets; + private ResourceSnippetParametersBuilder resourceBuilder; + + private RestDocumentationFilterBuilder(String identifier) { + this.identifier = identifier; + this.resourceBuilder = new ResourceSnippetParametersBuilder(); + this.snippets = new ArrayList<>(); + } + + public RestDocumentationFilterBuilder(String identifierPrefix, String identifier) { + this(identifierPrefix + IDENTIFIER_DELIMITER + identifier); + } + + public RestDocumentationFilterBuilder request(RestDocumentationRequest request) { + resourceBuilder = request.getResourceBuilder(); + snippets.addAll(request.getSnippets()); + return this; + } + + public RestDocumentationFilterBuilder response(RestDocumentationResponse response) { + snippets.addAll(response.getSnippets()); + return this; + } + + public RestDocumentationFilter build() { + return document( + identifier, + resourceBuilder, + REQUEST_PREPROCESSOR, + RESPONSE_PREPROCESSOR, + snippets.toArray(Snippet[]::new) + ); + } +} diff --git a/src/test/java/com/debatetimer/controller/RestDocumentationRequest.java b/src/test/java/com/debatetimer/controller/RestDocumentationRequest.java new file mode 100644 index 00000000..6157034f --- /dev/null +++ b/src/test/java/com/debatetimer/controller/RestDocumentationRequest.java @@ -0,0 +1,63 @@ +package com.debatetimer.controller; + +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +import com.epages.restdocs.apispec.ResourceSnippetParametersBuilder; +import java.util.LinkedList; +import java.util.List; +import org.springframework.restdocs.headers.HeaderDescriptor; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import org.springframework.restdocs.snippet.Snippet; + +public class RestDocumentationRequest { + + private final ResourceSnippetParametersBuilder resourceBuilder; + private final List snippets; + + public RestDocumentationRequest() { + this.resourceBuilder = new ResourceSnippetParametersBuilder(); + this.snippets = new LinkedList<>(); + } + + public RestDocumentationRequest tag(Tag tag) { + resourceBuilder.tag(tag.getDisplayName()); + return this; + } + + public RestDocumentationRequest summary(String summary) { + resourceBuilder.summary(summary); + return this; + } + + public RestDocumentationRequest pathParameter(ParameterDescriptor... descriptors) { + snippets.add(pathParameters(descriptors)); + return this; + } + + public RestDocumentationRequest queryParameter(ParameterDescriptor... descriptors) { + snippets.add(queryParameters(descriptors)); + return this; + } + + public RestDocumentationRequest requestHeader(HeaderDescriptor... descriptors) { + snippets.add(requestHeaders(descriptors)); + return this; + } + + public RestDocumentationRequest requestBodyField(FieldDescriptor... descriptors) { + snippets.add(requestFields(descriptors)); + return this; + } + + public ResourceSnippetParametersBuilder getResourceBuilder() { + return resourceBuilder; + } + + public List getSnippets() { + return List.copyOf(snippets); + } +} diff --git a/src/test/java/com/debatetimer/controller/RestDocumentationResponse.java b/src/test/java/com/debatetimer/controller/RestDocumentationResponse.java new file mode 100644 index 00000000..2e25a24c --- /dev/null +++ b/src/test/java/com/debatetimer/controller/RestDocumentationResponse.java @@ -0,0 +1,33 @@ +package com.debatetimer.controller; + +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; + +import java.util.LinkedList; +import java.util.List; +import org.springframework.restdocs.headers.HeaderDescriptor; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.snippet.Snippet; + +public class RestDocumentationResponse { + + private final List snippets; + + public RestDocumentationResponse() { + this.snippets = new LinkedList<>(); + } + + public RestDocumentationResponse responseHeader(HeaderDescriptor... descriptors) { + snippets.add(responseHeaders(descriptors)); + return this; + } + + public RestDocumentationResponse responseBodyField(FieldDescriptor... descriptors) { + snippets.add(responseFields(descriptors)); + return this; + } + + public List getSnippets() { + return List.copyOf(snippets); + } +} diff --git a/src/test/java/com/debatetimer/controller/Tag.java b/src/test/java/com/debatetimer/controller/Tag.java new file mode 100644 index 00000000..4b244dea --- /dev/null +++ b/src/test/java/com/debatetimer/controller/Tag.java @@ -0,0 +1,18 @@ +package com.debatetimer.controller; + +public enum Tag { + + MEMBER_API("Member API"), + PARLIAMENTARY_API("Parliamentary Table API"), + ; + + private final String displayName; + + Tag(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/test/java/com/debatetimer/controller/member/MemberControllerTest.java b/src/test/java/com/debatetimer/controller/member/MemberControllerTest.java new file mode 100644 index 00000000..a0251731 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/member/MemberControllerTest.java @@ -0,0 +1,54 @@ +package com.debatetimer.controller.member; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.dto.member.MemberCreateRequest; +import com.debatetimer.dto.member.MemberCreateResponse; +import com.debatetimer.dto.member.TableResponses; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class MemberControllerTest extends BaseControllerTest { + + @Nested + class CreateMember { + + @Test + void 회원을_생성한다() { + MemberCreateRequest request = new MemberCreateRequest("커찬"); + + MemberCreateResponse response = given() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/member") + .then().statusCode(201) + .extract().as(MemberCreateResponse.class); + + assertThat(response.nickname()).isEqualTo(request.nickname()); + } + } + + @Nested + class GetTables { + + @Test + void 회원의_전체_토론_시간표를_조회한다() { + Member member = memberRepository.save(new Member("커찬")); + parliamentaryTableRepository.save(new ParliamentaryTable(member, "토론 시간표 A", "주제", 1800)); + parliamentaryTableRepository.save(new ParliamentaryTable(member, "토론 시간표 B", "주제", 1900)); + + TableResponses response = given() + .contentType(ContentType.JSON) + .queryParam("memberId", member.getId()) + .when().get("/api/table") + .then().statusCode(200) + .extract().as(TableResponses.class); + + assertThat(response.tables()).hasSize(2); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/member/MemberDocumentTest.java b/src/test/java/com/debatetimer/controller/member/MemberDocumentTest.java new file mode 100644 index 00000000..a9edd6d2 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/member/MemberDocumentTest.java @@ -0,0 +1,142 @@ +package com.debatetimer.controller.member; + +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; + +import com.debatetimer.controller.BaseDocumentTest; +import com.debatetimer.controller.RestDocumentationRequest; +import com.debatetimer.controller.RestDocumentationResponse; +import com.debatetimer.controller.Tag; +import com.debatetimer.dto.member.MemberCreateRequest; +import com.debatetimer.dto.member.MemberCreateResponse; +import com.debatetimer.dto.member.TableResponse; +import com.debatetimer.dto.member.TableResponses; +import com.debatetimer.dto.member.TableType; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class MemberDocumentTest extends BaseDocumentTest { + + @Nested + class CreateMember { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.MEMBER_API) + .summary("멤버 생성") + .requestBodyField( + fieldWithPath("nickname").type(STRING).description("멤버 닉네임") + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("id").type(NUMBER).description("멤버 ID"), + fieldWithPath("nickname").type(STRING).description("멤버 닉네임") + ); + + @Test + void 회원_생성_성공() { + MemberCreateRequest request = new MemberCreateRequest("커찬"); + MemberCreateResponse response = new MemberCreateResponse(1L, "커찬"); + when(memberService.createMember(request)).thenReturn(response); + + var document = document("member/create", 201) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/member") + .then().statusCode(201); + } + + @EnumSource( + value = ClientErrorCode.class, + names = {"INVALID_MEMBER_NICKNAME_LENGTH", "INVALID_MEMBER_NICKNAME_FORM"} + ) + @ParameterizedTest + void 회원_생성_실패(ClientErrorCode errorCode) { + MemberCreateRequest request = new MemberCreateRequest("커찬"); + when(memberService.createMember(request)).thenThrow(new DTClientErrorException(errorCode)); + + var document = document("member/create", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/member") + .then().statusCode(errorCode.getStatus().value()); + } + } + + @Nested + class GetTables { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.MEMBER_API) + .summary("멤버의 토론 시간표 조회") + .queryParameter( + parameterWithName("memberId").description("멤버 ID") + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("tables").type(ARRAY).description("멤버의 토론 테이블들"), + fieldWithPath("tables[].id").type(NUMBER).description("토론 테이블 ID (토론 타입 별로 ID를 가짐)"), + fieldWithPath("tables[].name").type(STRING).description("토론 테이블 이름"), + fieldWithPath("tables[].type").type(STRING).description("토론 타입"), + fieldWithPath("tables[].duration").type(NUMBER).description("소요 시간 (초)") + ); + + @Test + void 테이블_조회_성공() { + TableResponses response = new TableResponses(List.of( + new TableResponse(1L, "토론 테이블 1", TableType.PARLIAMENTARY, 1800), + new TableResponse(2L, "토론 테이블 2", TableType.PARLIAMENTARY, 2000) + )); + when(memberService.getTables(EXIST_MEMBER_ID)).thenReturn(response); + + var document = document("member/table", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("memberId", EXIST_MEMBER_ID) + .when().get("/api/table") + .then().statusCode(200); + } + + @EnumSource(value = ClientErrorCode.class, names = {"MEMBER_NOT_FOUND"}) + @ParameterizedTest + void 테이블_조회_실패(ClientErrorCode errorCode) { + when(memberService.getTables(EXIST_MEMBER_ID)).thenThrow(new DTClientErrorException(errorCode)); + + var document = document("member/table", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("memberId", EXIST_MEMBER_ID) + .when().get("/api/table") + .then().statusCode(errorCode.getStatus().value()); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/parliamentary/ParliamentaryControllerTest.java b/src/test/java/com/debatetimer/controller/parliamentary/ParliamentaryControllerTest.java new file mode 100644 index 00000000..2a13970a --- /dev/null +++ b/src/test/java/com/debatetimer/controller/parliamentary/ParliamentaryControllerTest.java @@ -0,0 +1,128 @@ +package com.debatetimer.controller.parliamentary; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.dto.parliamentary.request.ParliamentaryTableCreateRequest; +import com.debatetimer.dto.parliamentary.request.TableInfoCreateRequest; +import com.debatetimer.dto.parliamentary.request.TimeBoxCreateRequest; +import com.debatetimer.dto.parliamentary.response.ParliamentaryTableResponse; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ParliamentaryControllerTest extends BaseControllerTest { + + @Nested + class Save { + + @Test + void 의회식_테이블을_생성한다() { + Member bito = memberGenerator.generate("비토"); + ParliamentaryTableCreateRequest request = new ParliamentaryTableCreateRequest( + new TableInfoCreateRequest("비토 테이블", "주제"), + List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ) + ); + + ParliamentaryTableResponse response = given() + .contentType(ContentType.JSON) + .queryParam("memberId", bito.getId()) + .body(request) + .when().post("/api/table/parliamentary") + .then().statusCode(201) + .extract().as(ParliamentaryTableResponse.class); + + assertAll( + () -> assertThat(response.info().name()).isEqualTo(request.info().name()), + () -> assertThat(response.table()).hasSize(request.table().size()) + ); + } + } + + @Nested + class GetTable { + + @Test + void 의회식_테이블을_조회한다() { + Member bito = memberGenerator.generate("비토"); + ParliamentaryTable bitoTable = tableGenerator.generate(bito); + timeBoxGenerator.generate(bitoTable, 1); + timeBoxGenerator.generate(bitoTable, 2); + + ParliamentaryTableResponse response = given() + .contentType(ContentType.JSON) + .pathParam("tableId", bitoTable.getId()) + .queryParam("memberId", bito.getId()) + .when().get("/api/table/parliamentary/{tableId}") + .then().statusCode(200) + .extract().as(ParliamentaryTableResponse.class); + + assertAll( + () -> assertThat(response.id()).isEqualTo(bitoTable.getId()), + () -> assertThat(response.table()).hasSize(2) + ); + } + } + + @Nested + class UpdateTable { + + @Test + void 의회식_토론_테이블을_업데이트한다() { + Member bito = memberGenerator.generate("비토"); + ParliamentaryTable bitoTable = tableGenerator.generate(bito); + TableInfoCreateRequest renewTableInfo = new TableInfoCreateRequest("비토 테이블", "주제"); + List renewTimeBoxes = List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ); + ParliamentaryTableCreateRequest renewTableRequest = new ParliamentaryTableCreateRequest( + renewTableInfo, + renewTimeBoxes + ); + + ParliamentaryTableResponse response = given() + .contentType(ContentType.JSON) + .pathParam("tableId", bitoTable.getId()) + .queryParam("memberId", bito.getId()) + .body(renewTableRequest) + .when().put("/api/table/parliamentary/{tableId}") + .then().statusCode(200) + .extract().as(ParliamentaryTableResponse.class); + + assertAll( + () -> assertThat(response.id()).isEqualTo(bitoTable.getId()), + () -> assertThat(response.info().name()).isEqualTo(renewTableInfo.name()), + () -> assertThat(response.table()).hasSize(renewTimeBoxes.size()) + ); + } + } + + @Nested + class DeleteTable { + + @Test + void 의회식_토론_테이블을_삭제한다() { + Member bito = memberGenerator.generate("비토"); + ParliamentaryTable bitoTable = tableGenerator.generate(bito); + timeBoxGenerator.generate(bitoTable, 1); + timeBoxGenerator.generate(bitoTable, 2); + + given() + .contentType(ContentType.JSON) + .pathParam("tableId", bitoTable.getId()) + .queryParam("memberId", bito.getId()) + .when().delete("/api/table/parliamentary/{tableId}") + .then().statusCode(204); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/parliamentary/ParliamentaryDocumentTest.java b/src/test/java/com/debatetimer/controller/parliamentary/ParliamentaryDocumentTest.java new file mode 100644 index 00000000..2003e00b --- /dev/null +++ b/src/test/java/com/debatetimer/controller/parliamentary/ParliamentaryDocumentTest.java @@ -0,0 +1,372 @@ +package com.debatetimer.controller.parliamentary; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; + +import com.debatetimer.controller.BaseDocumentTest; +import com.debatetimer.controller.RestDocumentationRequest; +import com.debatetimer.controller.RestDocumentationResponse; +import com.debatetimer.controller.Tag; +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.dto.parliamentary.request.ParliamentaryTableCreateRequest; +import com.debatetimer.dto.parliamentary.request.TableInfoCreateRequest; +import com.debatetimer.dto.parliamentary.request.TimeBoxCreateRequest; +import com.debatetimer.dto.parliamentary.response.ParliamentaryTableResponse; +import com.debatetimer.dto.parliamentary.response.TableInfoResponse; +import com.debatetimer.dto.parliamentary.response.TimeBoxResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class ParliamentaryDocumentTest extends BaseDocumentTest { + + @Nested + class Save { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.PARLIAMENTARY_API) + .summary("새로운 의회식 토론 시간표 생성") + .queryParameter( + parameterWithName("memberId").description("멤버 ID") + ) + .requestBodyField( + fieldWithPath("info").type(OBJECT).description("토론 테이블 정보"), + fieldWithPath("info.name").type(STRING).description("테이블 이름"), + fieldWithPath("info.agenda").type(STRING).description("토론 주제"), + fieldWithPath("table").type(ARRAY).description("토론 테이블 구성"), + fieldWithPath("table[].stance").type(STRING).description("입장"), + fieldWithPath("table[].type").type(STRING).description("발언 유형"), + fieldWithPath("table[].time").type(NUMBER).description("발언 시간(초)"), + fieldWithPath("table[].speakerNumber").type(NUMBER).description("발언자 번호").optional() + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("id").type(NUMBER).description("테이블 ID"), + fieldWithPath("info").type(OBJECT).description("토론 테이블 정보"), + fieldWithPath("info.name").type(STRING).description("테이블 이름"), + fieldWithPath("info.agenda").type(STRING).description("토론 주제"), + fieldWithPath("table").type(ARRAY).description("토론 테이블 구성"), + fieldWithPath("table[].stance").type(STRING).description("입장"), + fieldWithPath("table[].type").type(STRING).description("발언 유형"), + fieldWithPath("table[].time").type(NUMBER).description("발언 시간(초)"), + fieldWithPath("table[].speakerNumber").type(NUMBER).description("발언자 번호").optional() + ); + + @Test + void 의회식_테이블_생성_성공() { + ParliamentaryTableCreateRequest request = new ParliamentaryTableCreateRequest( + new TableInfoCreateRequest("비토 테이블 1", "토론 주제"), + List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ) + ); + ParliamentaryTableResponse response = new ParliamentaryTableResponse( + 5L, + new TableInfoResponse("비토 테이블 1", "토론 주제"), + List.of( + new TimeBoxResponse(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxResponse(Stance.CONS, BoxType.OPENING, 3, 1) + ) + ); + when(parliamentaryService.save(eq(request), any())).thenReturn(response); + + var document = document("parliamentary/post", 201) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("memberId", EXIST_MEMBER_ID) + .body(request) + .when().post("/api/table/parliamentary") + .then().statusCode(201); + } + + @EnumSource( + value = ClientErrorCode.class, + names = { + "INVALID_TABLE_NAME_LENGTH", + "INVALID_TABLE_NAME_FORM", + "INVALID_TABLE_TIME", + "INVALID_TIME_BOX_TIME", + "INVALID_TIME_BOX_STANCE" + } + ) + @ParameterizedTest + void 의회식_테이블_생성_실패(ClientErrorCode errorCode) { + ParliamentaryTableCreateRequest request = new ParliamentaryTableCreateRequest( + new TableInfoCreateRequest("비토 테이블 1", "토론 주제"), + List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ) + ); + when(parliamentaryService.save(eq(request), any())).thenThrow(new DTClientErrorException(errorCode)); + + var document = document("parliamentary/post", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("memberId", EXIST_MEMBER_ID) + .body(request) + .when().post("/api/table/parliamentary") + .then().statusCode(errorCode.getStatus().value()); + } + } + + @Nested + class GetTable { + + private final RestDocumentationRequest requestDocument = request() + .summary("의회식 토론 시간표 조회") + .tag(Tag.PARLIAMENTARY_API) + .pathParameter( + parameterWithName("tableId").description("테이블 ID") + ) + .queryParameter( + parameterWithName("memberId").description("멤버 ID") + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("id").type(NUMBER).description("테이블 ID"), + fieldWithPath("info").type(OBJECT).description("토론 테이블 정보"), + fieldWithPath("info.name").type(STRING).description("테이블 이름"), + fieldWithPath("info.agenda").type(STRING).description("토론 주제"), + fieldWithPath("table").type(ARRAY).description("토론 테이블 구성"), + fieldWithPath("table[].stance").type(STRING).description("입장"), + fieldWithPath("table[].type").type(STRING).description("발언 유형"), + fieldWithPath("table[].time").type(NUMBER).description("발언 시간(초)"), + fieldWithPath("table[].speakerNumber").type(NUMBER).description("발언자 번호").optional() + ); + + @Test + void 의회식_테이블_조회_성공() { + long memberId = 4L; + long tableId = 5L; + ParliamentaryTableResponse response = new ParliamentaryTableResponse( + 5L, + new TableInfoResponse("비토 테이블 1", "토론 주제"), + List.of( + new TimeBoxResponse(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxResponse(Stance.CONS, BoxType.OPENING, 3, 1) + ) + ); + when(parliamentaryService.findTable(eq(tableId), any())).thenReturn(response); + + var document = document("parliamentary/get", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .pathParam("tableId", tableId) + .queryParam("memberId", memberId) + .when().get("/api/table/parliamentary/{tableId}") + .then().statusCode(200); + } + + @ParameterizedTest + @EnumSource(value = ClientErrorCode.class, names = {"TABLE_NOT_FOUND", "NOT_TABLE_OWNER"}) + void 의회식_테이블_조회_실패(ClientErrorCode errorCode) { + long memberId = 4L; + long tableId = 5L; + when(parliamentaryService.findTable(eq(tableId), any())).thenThrow(new DTClientErrorException(errorCode)); + + var document = document("parliamentary/get", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .pathParam("tableId", tableId) + .queryParam("memberId", memberId) + .when().get("/api/table/parliamentary/{tableId}") + .then().statusCode(errorCode.getStatus().value()); + } + } + + @Nested + class UpdateTable { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.PARLIAMENTARY_API) + .summary("의회식 토론 시간표 수정") + .pathParameter( + parameterWithName("tableId").description("테이블 ID") + ) + .queryParameter( + parameterWithName("memberId").description("멤버 ID") + ) + .requestBodyField( + fieldWithPath("info").type(OBJECT).description("토론 테이블 정보"), + fieldWithPath("info.name").type(STRING).description("테이블 이름"), + fieldWithPath("info.agenda").type(STRING).description("토론 주제"), + fieldWithPath("table").type(ARRAY).description("토론 테이블 구성"), + fieldWithPath("table[].stance").type(STRING).description("입장"), + fieldWithPath("table[].type").type(STRING).description("발언 유형"), + fieldWithPath("table[].time").type(NUMBER).description("발언 시간(초)"), + fieldWithPath("table[].speakerNumber").type(NUMBER).description("발언자 번호").optional() + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("id").type(NUMBER).description("테이블 ID"), + fieldWithPath("info").type(OBJECT).description("토론 테이블 정보"), + fieldWithPath("info.name").type(STRING).description("테이블 이름"), + fieldWithPath("info.agenda").type(STRING).description("토론 주제"), + fieldWithPath("table").type(ARRAY).description("토론 테이블 구성"), + fieldWithPath("table[].stance").type(STRING).description("입장"), + fieldWithPath("table[].type").type(STRING).description("발언 유형"), + fieldWithPath("table[].time").type(NUMBER).description("발언 시간(초)"), + fieldWithPath("table[].speakerNumber").type(NUMBER).description("발언자 번호").optional() + ); + + @Test + void 의회식_토론_테이블_수정() { + long memberId = 4L; + long tableId = 5L; + ParliamentaryTableCreateRequest request = new ParliamentaryTableCreateRequest( + new TableInfoCreateRequest("비토 테이블 2", "토론 주제 2"), + List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 300, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 300, 1) + ) + ); + ParliamentaryTableResponse response = new ParliamentaryTableResponse( + 5L, + new TableInfoResponse("비토 테이블 2", "토론 주제 2"), + List.of( + new TimeBoxResponse(Stance.PROS, BoxType.OPENING, 300, 1), + new TimeBoxResponse(Stance.CONS, BoxType.OPENING, 300, 1) + ) + ); + when(parliamentaryService.updateTable(eq(request), eq(tableId), any())).thenReturn(response); + + var document = document("parliamentary/put", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("memberId", memberId) + .pathParam("tableId", tableId) + .body(request) + .when().put("/api/table/parliamentary/{tableId}") + .then().statusCode(200); + } + + @EnumSource( + value = ClientErrorCode.class, + names = { + "INVALID_TABLE_NAME_LENGTH", + "INVALID_TABLE_NAME_FORM", + "INVALID_TABLE_TIME", + "INVALID_TIME_BOX_TIME", + "INVALID_TIME_BOX_STANCE", + "NOT_TABLE_OWNER" + } + ) + @ParameterizedTest + void 의회식_테이블_생성_실패(ClientErrorCode errorCode) { + long memberId = 4L; + long tableId = 5L; + ParliamentaryTableCreateRequest request = new ParliamentaryTableCreateRequest( + new TableInfoCreateRequest("비토 테이블 2", "토론 주제 2"), + List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 300, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 300, 1) + ) + ); + when(parliamentaryService.updateTable(eq(request), eq(tableId), any())) + .thenThrow(new DTClientErrorException(errorCode)); + + var document = document("parliamentary/put", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .pathParam("tableId", tableId) + .queryParam("memberId", memberId) + .body(request) + .when().put("/api/table/parliamentary/{tableId}") + .then().statusCode(errorCode.getStatus().value()); + } + } + + @Nested + class DeleteTable { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.PARLIAMENTARY_API) + .summary("의회식 토론 시간표 삭제") + .pathParameter( + parameterWithName("tableId").description("테이블 ID") + ) + .queryParameter( + parameterWithName("memberId").description("멤버 ID") + ); + + @Test + void 의회식_테이블_삭제_성공() { + long memberId = 4L; + long tableId = 5L; + doNothing().when(parliamentaryService).deleteTable(eq(tableId), any()); + + var document = document("parliamentary/delete", 204) + .request(requestDocument) + .build(); + + given(document) + .pathParam("tableId", tableId) + .queryParam("memberId", memberId) + .when().delete("/api/table/parliamentary/{tableId}") + .then().statusCode(204); + } + + @EnumSource(value = ClientErrorCode.class, names = {"TABLE_NOT_FOUND", "NOT_TABLE_OWNER"}) + @ParameterizedTest + void 의회식_테이블_삭제_실패(ClientErrorCode errorCode) { + long memberId = 4L; + long tableId = 5L; + doThrow(new DTClientErrorException(errorCode)).when(parliamentaryService).deleteTable(eq(tableId), any()); + + var document = document("parliamentary/delete", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .pathParam("tableId", tableId) + .queryParam("memberId", memberId) + .when().delete("/api/table/parliamentary/{tableId}") + .then().statusCode(errorCode.getStatus().value()); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/member/MemberTest.java b/src/test/java/com/debatetimer/domain/member/MemberTest.java new file mode 100644 index 00000000..0637e45e --- /dev/null +++ b/src/test/java/com/debatetimer/domain/member/MemberTest.java @@ -0,0 +1,40 @@ +package com.debatetimer.domain.member; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MemberTest { + + @Nested + class Validate { + + @ParameterizedTest + @ValueSource(strings = {"a bc가다", "가나 다ab"}) + void 닉네임은_영문과_한글_띄어쓰기만_가능하다(String nickname) { + assertThatCode(() -> new Member(nickname)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(ints = {0, Member.NICKNAME_MAX_LENGTH + 1}) + void 닉네임은_정해진_길이_이내여야_한다(int length) { + assertThatThrownBy(() -> new Member("f".repeat(length))) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_MEMBER_NICKNAME_LENGTH.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"abc12", "가나다12"}) + void 닉네임은_영문과_한글만_가능하다(String nickname) { + assertThatThrownBy(() -> new Member(nickname)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_MEMBER_NICKNAME_FORM.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTableTest.java b/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTableTest.java new file mode 100644 index 00000000..a3521064 --- /dev/null +++ b/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTableTest.java @@ -0,0 +1,62 @@ +package com.debatetimer.domain.parliamentary; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ParliamentaryTableTest { + + @Nested + class Validate { + + @ParameterizedTest + @ValueSource(strings = {"a bc가다9", "가0나 다ab"}) + void 테이블_이름은_영문과_한글_숫자_띄어쓰기만_가능하다(String name) { + Member member = new Member("member"); + assertThatCode(() -> new ParliamentaryTable(member, name, "agenda", 10)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(ints = {0, ParliamentaryTable.NAME_MAX_LENGTH + 1}) + void 테이블_이름은_정해진_길이_이내여야_한다(int length) { + Member member = new Member("member"); + assertThatThrownBy(() -> new ParliamentaryTable(member, "f".repeat(length), "agenda", 10)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TABLE_NAME_LENGTH.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"", "\t", "\n"}) + void 테이블_이름은_적어도_한_자_있어야_한다(String name) { + Member member = new Member("member"); + assertThatThrownBy(() -> new ParliamentaryTable(member, name, "agenda", 10)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TABLE_NAME_LENGTH.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"abc@", "가나다*", "abc\tde"}) + void 허용된_글자_이외의_문자는_불가능하다(String name) { + Member member = new Member("member"); + assertThatThrownBy(() -> new ParliamentaryTable(member, name, "agenda", 10)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TABLE_NAME_FORM.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, -60}) + void 테이블_시간은_양수만_가능하다(int duration) { + Member member = new Member("member"); + assertThatThrownBy(() -> new ParliamentaryTable(member, "name", "agenda", duration)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TABLE_TIME.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxTest.java b/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxTest.java new file mode 100644 index 00000000..d698b8e2 --- /dev/null +++ b/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxTest.java @@ -0,0 +1,49 @@ +package com.debatetimer.domain.parliamentary; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ParliamentaryTimeBoxTest { + + @Nested + class Validate { + + @Test + void 순서는_양수만_가능하다() { + ParliamentaryTable table = new ParliamentaryTable(); + assertThatThrownBy(() -> new ParliamentaryTimeBox(table, 0, Stance.CONS, BoxType.OPENING, 10, 1)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TIME_BOX_SEQUENCE.getMessage()); + } + + @Test + void 시간은_양수만_가능하다() { + ParliamentaryTable table = new ParliamentaryTable(); + assertThatThrownBy(() -> new ParliamentaryTimeBox(table, 1, Stance.CONS, BoxType.OPENING, 0, 1)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TIME_BOX_TIME.getMessage()); + } + + @Test + void 박스타입에_가능한_입장을_검증한다() { + ParliamentaryTable table = new ParliamentaryTable(); + assertThatCode(() -> new ParliamentaryTimeBox(table, 1, Stance.CONS, BoxType.OPENING, 10, 1)) + .doesNotThrowAnyException(); + } + + @Test + void 박스타입에_불가한_입장으로_생성을_시도하면_예외를_발생시킨다() { + ParliamentaryTable table = new ParliamentaryTable(); + assertThatThrownBy(() -> new ParliamentaryTimeBox(table, 1, Stance.NEUTRAL, BoxType.OPENING, 10, 1)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_TIME_BOX_STANCE.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxesTest.java b/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxesTest.java new file mode 100644 index 00000000..9376f493 --- /dev/null +++ b/src/test/java/com/debatetimer/domain/parliamentary/ParliamentaryTimeBoxesTest.java @@ -0,0 +1,32 @@ +package com.debatetimer.domain.parliamentary; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.domain.member.Member; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ParliamentaryTimeBoxesTest { + + @Nested + class SortedBySequence { + + @Test + void 타임박스의_순서에_따라_정렬된다() { + Member member = new Member("콜리"); + ParliamentaryTable testTable = new ParliamentaryTable(member, "토론 테이블", "주제", 1800); + ParliamentaryTimeBox firstBox = new ParliamentaryTimeBox(testTable, 1, Stance.PROS, BoxType.OPENING, 300, 1); + ParliamentaryTimeBox secondBox = new ParliamentaryTimeBox(testTable, 2, Stance.PROS, BoxType.OPENING, 300, 1); + List timeBoxes = new ArrayList<>(Arrays.asList(secondBox, firstBox)); + + ParliamentaryTimeBoxes actual = new ParliamentaryTimeBoxes(timeBoxes); + + assertThat(actual.getTimeBoxes()).containsExactly(firstBox, secondBox); + } + } +} diff --git a/src/test/java/com/debatetimer/fixture/MemberGenerator.java b/src/test/java/com/debatetimer/fixture/MemberGenerator.java new file mode 100644 index 00000000..8af700ff --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/MemberGenerator.java @@ -0,0 +1,20 @@ +package com.debatetimer.fixture; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.repository.member.MemberRepository; +import org.springframework.stereotype.Component; + +@Component +public class MemberGenerator { + + private final MemberRepository memberRepository; + + public MemberGenerator(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public Member generate(String nickName) { + Member member = new Member(nickName); + return memberRepository.save(member); + } +} diff --git a/src/test/java/com/debatetimer/fixture/ParliamentaryTableGenerator.java b/src/test/java/com/debatetimer/fixture/ParliamentaryTableGenerator.java new file mode 100644 index 00000000..cbb0340f --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/ParliamentaryTableGenerator.java @@ -0,0 +1,21 @@ +package com.debatetimer.fixture; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.repository.parliamentary.ParliamentaryTableRepository; +import org.springframework.stereotype.Component; + +@Component +public class ParliamentaryTableGenerator { + + private final ParliamentaryTableRepository parliamentaryTableRepository; + + public ParliamentaryTableGenerator(ParliamentaryTableRepository parliamentaryTableRepository) { + this.parliamentaryTableRepository = parliamentaryTableRepository; + } + + public ParliamentaryTable generate(Member member) { + ParliamentaryTable table = new ParliamentaryTable(member, "토론 테이블", "주제", 1800); + return parliamentaryTableRepository.save(table); + } +} diff --git a/src/test/java/com/debatetimer/fixture/ParliamentaryTimeBoxGenerator.java b/src/test/java/com/debatetimer/fixture/ParliamentaryTimeBoxGenerator.java new file mode 100644 index 00000000..d4fe23d7 --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/ParliamentaryTimeBoxGenerator.java @@ -0,0 +1,24 @@ +package com.debatetimer.fixture; + +import com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; +import com.debatetimer.repository.parliamentary.ParliamentaryTimeBoxRepository; +import org.springframework.stereotype.Component; + +@Component +public class ParliamentaryTimeBoxGenerator { + + private final ParliamentaryTimeBoxRepository parliamentaryTimeBoxRepository; + + public ParliamentaryTimeBoxGenerator(ParliamentaryTimeBoxRepository parliamentaryTimeBoxRepository) { + this.parliamentaryTimeBoxRepository = parliamentaryTimeBoxRepository; + } + + public ParliamentaryTimeBox generate(ParliamentaryTable testTable, int sequence) { + ParliamentaryTimeBox timeBox = new ParliamentaryTimeBox(testTable, sequence, Stance.PROS, BoxType.OPENING, 180, + 1); + return parliamentaryTimeBoxRepository.save(timeBox); + } +} diff --git a/src/test/java/com/debatetimer/repository/BaseRepositoryTest.java b/src/test/java/com/debatetimer/repository/BaseRepositoryTest.java new file mode 100644 index 00000000..baf01463 --- /dev/null +++ b/src/test/java/com/debatetimer/repository/BaseRepositoryTest.java @@ -0,0 +1,22 @@ +package com.debatetimer.repository; + +import com.debatetimer.fixture.MemberGenerator; +import com.debatetimer.fixture.ParliamentaryTableGenerator; +import com.debatetimer.fixture.ParliamentaryTimeBoxGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@Import({MemberGenerator.class, ParliamentaryTableGenerator.class, ParliamentaryTimeBoxGenerator.class}) +@DataJpaTest +public abstract class BaseRepositoryTest { + + @Autowired + protected MemberGenerator memberGenerator; + + @Autowired + protected ParliamentaryTableGenerator tableGenerator; + + @Autowired + protected ParliamentaryTimeBoxGenerator timeBoxGenerator; +} diff --git a/src/test/java/com/debatetimer/repository/parliamentary/ParliamentaryTableRepositoryTest.java b/src/test/java/com/debatetimer/repository/parliamentary/ParliamentaryTableRepositoryTest.java new file mode 100644 index 00000000..0ccdb063 --- /dev/null +++ b/src/test/java/com/debatetimer/repository/parliamentary/ParliamentaryTableRepositoryTest.java @@ -0,0 +1,58 @@ +package com.debatetimer.repository.parliamentary; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.BaseRepositoryTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ParliamentaryTableRepositoryTest extends BaseRepositoryTest { + + @Autowired + private ParliamentaryTableRepository tableRepository; + + @Nested + class FindAllByMember { + + @Test + void 특정_회원의_테이블만_조회한다() { + Member chan = memberGenerator.generate("커찬"); + Member bito = memberGenerator.generate("비토"); + ParliamentaryTable chanTable1 = tableGenerator.generate(chan); + ParliamentaryTable chanTable2 = tableGenerator.generate(chan); + ParliamentaryTable bitoTable = tableGenerator.generate(bito); + + List foundKeoChanTables = tableRepository.findAllByMember(chan); + + assertThat(foundKeoChanTables).containsExactly(chanTable1, chanTable2); + } + } + + @Nested + class GetById { + + @Test + void 특정_아이디의_테이블을_조회한다() { + Member chan = memberGenerator.generate("커찬"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + + ParliamentaryTable foundChanTable = tableRepository.getById(chanTable.getId().longValue()); + + assertThat(foundChanTable).usingRecursiveComparison().isEqualTo(chanTable); + } + + @Test + void 특정_아이디의_테이블이_없으면_에러를_발생시킨다() { + assertThatThrownBy(() -> tableRepository.getById(1L)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.TABLE_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/repository/parliamentary/ParliamentaryTimeBoxRepositoryTest.java b/src/test/java/com/debatetimer/repository/parliamentary/ParliamentaryTimeBoxRepositoryTest.java new file mode 100644 index 00000000..07cac513 --- /dev/null +++ b/src/test/java/com/debatetimer/repository/parliamentary/ParliamentaryTimeBoxRepositoryTest.java @@ -0,0 +1,39 @@ +package com.debatetimer.repository.parliamentary; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; +import com.debatetimer.repository.BaseRepositoryTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ParliamentaryTimeBoxRepositoryTest extends BaseRepositoryTest { + + @Autowired + private ParliamentaryTimeBoxRepository parliamentaryTimeBoxRepository; + + @Nested + class FindAllByParliamentaryTable { + + @Test + void 특정_테이블의_타임박스를_모두_조회한다() { + Member chan = memberGenerator.generate("커찬"); + Member bito = memberGenerator.generate("비토"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + ParliamentaryTable bitoTable = tableGenerator.generate(bito); + ParliamentaryTimeBox chanBox1 = timeBoxGenerator.generate(chanTable, 1); + ParliamentaryTimeBox chanBox2 = timeBoxGenerator.generate(chanTable, 2); + ParliamentaryTimeBox bitoBox1 = timeBoxGenerator.generate(bitoTable, 2); + ParliamentaryTimeBox bitoBox2 = timeBoxGenerator.generate(bitoTable, 2); + + List foundBoxes = parliamentaryTimeBoxRepository.findAllByParliamentaryTable( + chanTable); + + assertThat(foundBoxes).containsExactly(chanBox1, chanBox2); + } + } +} diff --git a/src/test/java/com/debatetimer/service/BaseServiceTest.java b/src/test/java/com/debatetimer/service/BaseServiceTest.java new file mode 100644 index 00000000..e68e262a --- /dev/null +++ b/src/test/java/com/debatetimer/service/BaseServiceTest.java @@ -0,0 +1,35 @@ +package com.debatetimer.service; + +import com.debatetimer.DataBaseCleaner; +import com.debatetimer.fixture.MemberGenerator; +import com.debatetimer.fixture.ParliamentaryTableGenerator; +import com.debatetimer.fixture.ParliamentaryTimeBoxGenerator; +import com.debatetimer.repository.member.MemberRepository; +import com.debatetimer.repository.parliamentary.ParliamentaryTableRepository; +import com.debatetimer.repository.parliamentary.ParliamentaryTimeBoxRepository; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@ExtendWith(DataBaseCleaner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +public abstract class BaseServiceTest { + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ParliamentaryTableRepository parliamentaryTableRepository; + + @Autowired + protected ParliamentaryTimeBoxRepository timeBoxRepository; + + @Autowired + protected MemberGenerator memberGenerator; + + @Autowired + protected ParliamentaryTableGenerator tableGenerator; + + @Autowired + protected ParliamentaryTimeBoxGenerator timeBoxGenerator; +} diff --git a/src/test/java/com/debatetimer/service/member/MemberServiceTest.java b/src/test/java/com/debatetimer/service/member/MemberServiceTest.java new file mode 100644 index 00000000..be13f55e --- /dev/null +++ b/src/test/java/com/debatetimer/service/member/MemberServiceTest.java @@ -0,0 +1,63 @@ +package com.debatetimer.service.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.dto.member.MemberCreateRequest; +import com.debatetimer.dto.member.MemberCreateResponse; +import com.debatetimer.dto.member.TableResponses; +import com.debatetimer.service.BaseServiceTest; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class MemberServiceTest extends BaseServiceTest { + + @Autowired + private MemberService memberService; + + @Nested + class CreateMember { + + @Test + void 회원를_생성한다() { + MemberCreateRequest request = new MemberCreateRequest("커찬"); + + MemberCreateResponse actual = memberService.createMember(request); + + Optional foundMember = memberRepository.findById(actual.id()); + assertAll( + () -> assertThat(actual.nickname()).isEqualTo(request.nickname()), + () -> assertThat(foundMember).isPresent() + ); + } + + @Test + void 기존_닉네임을_가진_회원이_있다면_해당_회원을_반환한다() { + Member existedMember = memberGenerator.generate("커찬"); + MemberCreateRequest request = new MemberCreateRequest("커찬"); + + MemberCreateResponse actual = memberService.createMember(request); + + assertThat(actual.id()).isEqualTo(existedMember.getId()); + } + } + + @Nested + class GetTables { + + @Test + void 회원의_전체_토론_시간표를_조회한다() { + Member member = memberRepository.save(new Member("커찬")); + parliamentaryTableRepository.save(new ParliamentaryTable(member, "토론 시간표 A", "주제", 1800)); + parliamentaryTableRepository.save(new ParliamentaryTable(member, "토론 시간표 B", "주제", 1900)); + + TableResponses response = memberService.getTables(member.getId()); + + assertThat(response.tables()).hasSize(2); + } + } +} diff --git a/src/test/java/com/debatetimer/service/parliamentary/ParliamentaryServiceTest.java b/src/test/java/com/debatetimer/service/parliamentary/ParliamentaryServiceTest.java new file mode 100644 index 00000000..89196065 --- /dev/null +++ b/src/test/java/com/debatetimer/service/parliamentary/ParliamentaryServiceTest.java @@ -0,0 +1,170 @@ +package com.debatetimer.service.parliamentary; + +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 com.debatetimer.domain.BoxType; +import com.debatetimer.domain.Stance; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.parliamentary.ParliamentaryTable; +import com.debatetimer.domain.parliamentary.ParliamentaryTimeBox; +import com.debatetimer.dto.parliamentary.request.ParliamentaryTableCreateRequest; +import com.debatetimer.dto.parliamentary.request.TableInfoCreateRequest; +import com.debatetimer.dto.parliamentary.request.TimeBoxCreateRequest; +import com.debatetimer.dto.parliamentary.response.ParliamentaryTableResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.BaseServiceTest; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ParliamentaryServiceTest extends BaseServiceTest { + + @Autowired + private ParliamentaryService parliamentaryService; + + @Nested + class Save { + + @Test + void 의회식_토론_테이블을_생성한다() { + Member chan = memberGenerator.generate("커찬"); + TableInfoCreateRequest requestTableInfo = new TableInfoCreateRequest("커찬의 테이블", "주제"); + List requestTimeBoxes = List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ); + ParliamentaryTableCreateRequest chanTableRequest = new ParliamentaryTableCreateRequest( + requestTableInfo, + requestTimeBoxes + ); + + ParliamentaryTableResponse savedTableResponse = parliamentaryService.save(chanTableRequest, chan); + Optional foundTable = parliamentaryTableRepository.findById(savedTableResponse.id()); + List foundTimeBoxes = timeBoxRepository.findAllByParliamentaryTable(foundTable.get()); + + assertAll( + () -> assertThat(foundTable.get().getName()).isEqualTo(requestTableInfo.name()), + () -> assertThat(foundTimeBoxes).hasSize(requestTimeBoxes.size()) + ); + } + } + + @Nested + class FindTable { + + @Test + void 의회식_토론_테이블을_조회한다() { + Member chan = memberGenerator.generate("커찬"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + timeBoxGenerator.generate(chanTable, 1); + timeBoxGenerator.generate(chanTable, 2); + + ParliamentaryTableResponse foundResponse = parliamentaryService.findTable(chanTable.getId(), chan); + + assertAll( + () -> assertThat(foundResponse.id()).isEqualTo(chanTable.getId()), + () -> assertThat(foundResponse.table()).hasSize(2) + ); + } + + @Test + void 회원_소유가_아닌_테이블_조회_시_예외를_발생시킨다() { + Member chan = memberGenerator.generate("커찬"); + Member coli = memberGenerator.generate("콜리"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + long chanTableId = chanTable.getId(); + + assertThatThrownBy(() -> parliamentaryService.findTable(chanTableId, coli)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.NOT_TABLE_OWNER.getMessage()); + } + } + + @Nested + class UpdateTable { + + @Test + void 의회식_토론_테이블을_수정한다() { + Member chan = memberGenerator.generate("커찬"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + TableInfoCreateRequest renewTableInfo = new TableInfoCreateRequest("커찬 테이블", "주제"); + List renewTimeBoxes = List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ); + ParliamentaryTableCreateRequest renewTableRequest = new ParliamentaryTableCreateRequest( + renewTableInfo, + renewTimeBoxes + ); + + ParliamentaryTableResponse updatedTable = parliamentaryService.updateTable(renewTableRequest, + chanTable.getId(), chan); + + assertAll( + () -> assertThat(updatedTable.id()).isEqualTo(chanTable.getId()), + () -> assertThat(updatedTable.info().name()).isEqualTo(renewTableInfo.name()), + () -> assertThat(updatedTable.table()).hasSize(renewTimeBoxes.size()) + ); + } + + @Test + void 회원_소유가_아닌_테이블_수정_시_예외를_발생시킨다() { + Member chan = memberGenerator.generate("커찬"); + Member coli = memberGenerator.generate("콜리"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + long chanTableId = chanTable.getId(); + TableInfoCreateRequest renewTableInfo = new TableInfoCreateRequest("새로운 테이블", "주제"); + List renewTimeBoxes = List.of( + new TimeBoxCreateRequest(Stance.PROS, BoxType.OPENING, 3, 1), + new TimeBoxCreateRequest(Stance.CONS, BoxType.OPENING, 3, 1) + ); + ParliamentaryTableCreateRequest renewTableRequest = new ParliamentaryTableCreateRequest( + renewTableInfo, + renewTimeBoxes + ); + + assertThatThrownBy(() -> parliamentaryService.updateTable(renewTableRequest, chanTableId, coli)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.NOT_TABLE_OWNER.getMessage()); + } + } + + @Nested + class DeleteTable { + + @Test + void 의회식_토론_테이블을_삭제한다() { + Member chan = memberGenerator.generate("커찬"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + timeBoxGenerator.generate(chanTable, 1); + timeBoxGenerator.generate(chanTable, 2); + + parliamentaryService.deleteTable(chanTable.getId(), chan); + + Optional foundTable = parliamentaryTableRepository.findById(chanTable.getId()); + List timeBoxes = timeBoxRepository.findAllByParliamentaryTable(chanTable); + + assertAll( + () -> assertThat(foundTable).isEmpty(), + () -> assertThat(timeBoxes).isEmpty() + ); + } + + @Test + void 회원_소유가_아닌_테이블_삭제_시_예외를_발생시킨다() { + Member chan = memberGenerator.generate("커찬"); + Member coli = memberGenerator.generate("콜리"); + ParliamentaryTable chanTable = tableGenerator.generate(chan); + Long chanTableId = chanTable.getId(); + + assertThatThrownBy(() -> parliamentaryService.deleteTable(chanTableId, coli)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.NOT_TABLE_OWNER.getMessage()); + } + } +} diff --git a/src/test/java/com/debatetimer/view/exporter/BoxTypeViewTest.java b/src/test/java/com/debatetimer/view/exporter/BoxTypeViewTest.java new file mode 100644 index 00000000..55c552de --- /dev/null +++ b/src/test/java/com/debatetimer/view/exporter/BoxTypeViewTest.java @@ -0,0 +1,22 @@ +package com.debatetimer.view.exporter; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.debatetimer.domain.BoxType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class BoxTypeViewTest { + + @Nested + class MapView { + + @ParameterizedTest + @EnumSource(value = BoxType.class) + void 타임박스_타입과_일치하는_메시지를_반환한다(BoxType boxType) { + assertThatCode(() -> BoxTypeView.mapView(boxType)) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..e105e48e --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,26 @@ +spring: + profiles: + active: test + +--- + +spring: + config: + activate: + on-profile: test + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:database + username: sa + password: + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: true + +cors: + origin: http://test.debate-timer.com