diff --git a/.github/workflows/backend-ci-cd-dev.yml b/.github/workflows/backend-ci-cd-dev.yml new file mode 100644 index 000000000..a8fa76037 --- /dev/null +++ b/.github/workflows/backend-ci-cd-dev.yml @@ -0,0 +1,63 @@ +name: Backend CI/CD dev + +on: + pull_request: + branches: [ "develop-be" ] + +jobs: + ci: + runs-on: [self-hosted, dev] + + defaults: + run: + shell: bash + working-directory: ./backend + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + sudo docker build --platform linux/arm64 -t staccato/staccato:dev -f Dockerfile.dev . + + - name: Docker Hub Push + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker push staccato/staccato:dev + + cd: + needs: ci + runs-on: [self-hosted, dev] + steps: + - name: Pull Docker image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker pull staccato/staccato:dev + + - name: Docker Compose up + run: | + sudo docker compose -f /home/ubuntu/staccato/docker-compose.yml up -d + sudo docker image prune -af diff --git a/.github/workflows/backend-ci-cd-prod.yml b/.github/workflows/backend-ci-cd-prod.yml new file mode 100644 index 000000000..b151e9bb2 --- /dev/null +++ b/.github/workflows/backend-ci-cd-prod.yml @@ -0,0 +1,70 @@ +name: Backend CI/CD prod + +on: + push: + branches: [ "main", "develop" ] +jobs: + ci: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + sudo docker build --platform linux/arm64 -t staccato/staccato:prod -f Dockerfile.prod . + + - name: Docker Hub Push + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker push staccato/staccato:prod + + cd: + needs: ci + runs-on: [self-hosted, prod] + steps: + - name: Pull Docker image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker pull staccato/staccato:prod + + - name: Stop and remove existing container + run: | + sudo docker stop staccato-backend-app || true + sudo docker rm staccato-backend-app || true + + - name: Docker run + run: | + sudo docker run --env-file /home/ubuntu/staccato/.env \ + -v /home/ubuntu/staccato/logs:/logs \ + -p 8080:8080 \ + -d --name staccato-backend-app staccato/staccato:prod + sudo docker image prune -af diff --git a/.github/workflows/backend-ci-cd-stage.yml b/.github/workflows/backend-ci-cd-stage.yml new file mode 100644 index 000000000..12e134329 --- /dev/null +++ b/.github/workflows/backend-ci-cd-stage.yml @@ -0,0 +1,63 @@ +name: Backend CI/CD stage + +on: + push: + branches: [ "develop-be" ] + +jobs: + ci: + runs-on: [self-hosted, stage] + + defaults: + run: + shell: bash + working-directory: ./backend + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Login to Docker Hub + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + + - name: Docker Image Build + run: | + sudo docker build --platform linux/arm64 -t staccato/staccato:stage -f Dockerfile.stage . + + - name: Docker Hub Push + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker push staccato/staccato:stage + + cd: + needs: ci + runs-on: [self-hosted, stage] + steps: + - name: Pull Docker image + run: | + sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} + sudo docker pull staccato/staccato:stage + + - name: Docker Compose up + run: | + sudo docker-compose -f /home/ubuntu/staccato/docker-compose.yml up -d + sudo docker image prune -af diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 000000000..ebe7e0afe --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,51 @@ +name: Backend CI + +on: + pull_request: + branches: [ "develop-be" ] + +permissions: write-all + +jobs: + build: + + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Test with Gradle + run: ./gradlew build + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: ${{ github.workspace }}/backend/build/test-results/**/*.xml + + - name: Jacoco Report to PR + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: ${{ github.workspace }}/backend/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 70 + min-coverage-changed-files: 70 + title: "🌻Test Coverage Report" + update-comment: true diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..a521d28f4 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +.env + +.idea + +.gradle + +mysql diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/backend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 000000000..35d757d26 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,5 @@ +FROM openjdk:17 +EXPOSE 8080 +ARG JAR_FILE=./build/libs/*-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar","-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=dev","app.jar"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 000000000..dad47861b --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,6 @@ +FROM openjdk:17 +EXPOSE 8080 +ARG JAR_FILE=./build/libs/*-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar + +ENTRYPOINT ["java", "-jar","-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=prod","app.jar"] diff --git a/backend/Dockerfile.stage b/backend/Dockerfile.stage new file mode 100644 index 000000000..6f195c1e4 --- /dev/null +++ b/backend/Dockerfile.stage @@ -0,0 +1,5 @@ +FROM openjdk:17 +EXPOSE 8080 +ARG JAR_FILE=./build/libs/*-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar","-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=stage","app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 000000000..606ec4c3d --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,78 @@ +plugins { + id 'java' + id 'jacoco' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' +} + +group = 'com' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +jacoco { + toolVersion '0.8.8' +} + +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' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'software.amazon.awssdk:s3:2.26.21' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + testImplementation 'io.rest-assured:rest-assured:5.3.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec")) + + reports { + html.required.set(true) + xml.required.set(true) + csv.required.set(false) + } +} + +/*jacocoTestCoverageVerification { + violationRules { + rule { + element 'CLASS' + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.7 + } + + excludes = ['*.config.*','*.*Builder.*'] + } + } +}*/ diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 000000000..af3f73bdf --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.8" +services: + database: + container_name: staccato-database + image: mysql:8.0.30 + environment: + - MYSQL_DATABASE=staccato + - MYSQL_USER=${MYSQL_USER} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + volumes: + - ./mysql:/var/lib/mysql + ports: + - "3306:3306" + restart: always + networks: + - springboot-mysql-network + application: + container_name: staccato-backend-app + image: ${STACCATO_IMAGE} + depends_on: + - database + environment: + - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} + ports: + - "8080:8080" + restart: always + networks: + - springboot-mysql-network + +networks: + springboot-mysql-network: + driver: bridge diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e6441136f Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a4413138c --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 000000000..b740cf133 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright Β© 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», +# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; +# * compound commands having a testable exit status, especially Β«caseΒ»; +# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 000000000..acf487e12 --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'staccato' diff --git a/backend/src/main/java/com/staccato/StaccatoApplication.java b/backend/src/main/java/com/staccato/StaccatoApplication.java new file mode 100644 index 000000000..a2fea7d56 --- /dev/null +++ b/backend/src/main/java/com/staccato/StaccatoApplication.java @@ -0,0 +1,13 @@ +package com.staccato; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class StaccatoApplication { + + public static void main(String[] args) { + SpringApplication.run(StaccatoApplication.class, args); + } + +} diff --git a/backend/src/main/java/com/staccato/auth/controller/AuthController.java b/backend/src/main/java/com/staccato/auth/controller/AuthController.java new file mode 100644 index 000000000..cc8df62a1 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/controller/AuthController.java @@ -0,0 +1,26 @@ +package com.staccato.auth.controller; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.auth.service.AuthService; +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { + LoginResponse loginResponse = authService.login(loginRequest); + return ResponseEntity.ok(loginResponse); + } +} diff --git a/backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java b/backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java new file mode 100644 index 000000000..6b2f2aaf8 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/controller/AuthControllerDocs.java @@ -0,0 +1,34 @@ +package com.staccato.auth.controller; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; + +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Authorization", description = "Authorization API") +public interface AuthControllerDocs { + @Operation(summary = "등둝 및 둜그인", description = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ 졜초 μ‹€ν–‰ν•  λ•Œ ν•œ 번만 λ‹‰λ„€μž„ μž…λ ₯을 λ°›κ³ , 식별 μ½”λ“œλ₯Ό λ°œκΈ‰ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "등둝 및 둜그인 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) 이미 μ‘΄μž¬ν•˜λŠ” λ‹‰λ„€μž„μΌ λ•Œ + + (2) λ‹‰λ„€μž„μ˜ ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ (ν•œκΈ€, μ˜μ–΄, λ§ˆμΉ¨ν‘œ(.), 언더바(_)만 μ‚¬μš© κ°€λŠ₯) + + (3) λ‹‰λ„€μž„μ΄ 20자λ₯Ό μ΄ˆκ³Όν•˜μ˜€μ„ λ•Œ + + (4) λ‹‰λ„€μž„μ„ μž…λ ₯ν•˜μ§€ μ•Šμ•˜μ„ λ•Œ + """, + responseCode = "400") + }) + ResponseEntity login(@Valid LoginRequest loginRequest); +} diff --git a/backend/src/main/java/com/staccato/auth/service/AuthService.java b/backend/src/main/java/com/staccato/auth/service/AuthService.java new file mode 100644 index 000000000..cea3eb95a --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/service/AuthService.java @@ -0,0 +1,66 @@ +package com.staccato.auth.service; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; +import com.staccato.config.auth.AdminProperties; +import com.staccato.config.auth.TokenProvider; +import com.staccato.config.log.LogForm; +import com.staccato.exception.StaccatoException; +import com.staccato.exception.UnauthorizedException; +import com.staccato.member.domain.Member; +import com.staccato.member.domain.Nickname; +import com.staccato.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@EnableConfigurationProperties(AdminProperties.class) +public class AuthService { + private final MemberRepository memberRepository; + private final TokenProvider tokenProvider; + private final AdminProperties adminProperties; + private final Environment environment; + + @Transactional + public LoginResponse login(LoginRequest loginRequest) { + if (isNotProdProfile() && adminProperties.key().equals(loginRequest.nickname())) { + return new LoginResponse(adminProperties.token()); + } + Member member = createMember(loginRequest); + String token = tokenProvider.create(member); + return new LoginResponse(token); + } + + private boolean isNotProdProfile() { + return !environment.acceptsProfiles(Profiles.of("prod")); + } + + private Member createMember(LoginRequest loginRequest) { + Member member = loginRequest.toMember(); + validateNickname(member.getNickname()); + return memberRepository.save(member); + } + + private void validateNickname(Nickname nickname) { + if (memberRepository.existsByNickname(nickname)) { + throw new StaccatoException("이미 μ‘΄μž¬ν•˜λŠ” λ‹‰λ„€μž„μž…λ‹ˆλ‹€. λ‹€μ‹œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + } + } + + public Member extractFromToken(String token) { + Member member = memberRepository.findById(tokenProvider.extractMemberId(token)) + .orElseThrow(UnauthorizedException::new); + log.info(LogForm.LOGIN_MEMBER_FORM, member.getId(), member.getNickname().getNickname()); + return member; + } +} diff --git a/backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java b/backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java new file mode 100644 index 000000000..8402bce00 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/service/dto/request/LoginRequest.java @@ -0,0 +1,30 @@ +package com.staccato.auth.service.dto.request; + +import java.util.Objects; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "νšŒμ›μ„ λ“±λ‘ν•˜κΈ° μœ„ν•œ μš”μ²­ ν˜•μ‹μž…λ‹ˆλ‹€.") +public record LoginRequest( + @Schema(example = "hi_staccato") + @NotBlank(message = "λ‹‰λ„€μž„μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(min = 1, max = 20, message = "1자 이상 20자 μ΄ν•˜μ˜ λ‹‰λ„€μž„μœΌλ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”.") + String nickname +) { + public LoginRequest { + if(!Objects.isNull(nickname)){ + nickname = nickname.trim(); + } + } + + public Member toMember() { + return Member.builder() + .nickname(nickname) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java b/backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java new file mode 100644 index 000000000..07b6097e7 --- /dev/null +++ b/backend/src/main/java/com/staccato/auth/service/dto/response/LoginResponse.java @@ -0,0 +1,10 @@ +package com.staccato.auth.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "νšŒμ› 등둝 μ‹œ λ°œκΈ‰λ˜λŠ” 토큰에 λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record LoginResponse( + @Schema(example = "{tokenString}") + String token +) { +} diff --git a/backend/src/main/java/com/staccato/comment/controller/CommentController.java b/backend/src/main/java/com/staccato/comment/controller/CommentController.java new file mode 100644 index 000000000..af50a13e5 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/controller/CommentController.java @@ -0,0 +1,73 @@ +package com.staccato.comment.controller; + +import java.net.URI; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.comment.controller.docs.CommentControllerDocs; +import com.staccato.comment.service.CommentService; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.config.auth.LoginMember; +import com.staccato.member.domain.Member; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +@Validated +public class CommentController implements CommentControllerDocs { + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment( + @LoginMember Member member, + @Valid @RequestBody CommentRequest commentRequest + ) { + long commentId = commentService.createComment(commentRequest, member); + return ResponseEntity.created(URI.create("/comments/" + commentId)) + .build(); + } + + @GetMapping + public ResponseEntity readCommentsByMomentId( + @LoginMember Member member, + @RequestParam @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId + ) { + CommentResponses commentResponses = commentService.readAllCommentsByMomentId(member, momentId); + return ResponseEntity.ok().body(commentResponses); + } + + @PutMapping + public ResponseEntity updateComment( + @LoginMember Member member, + @RequestParam @Min(value = 1L, message = "λŒ“κΈ€ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long commentId, + @Valid @RequestBody CommentUpdateRequest commentUpdateRequest + ) { + commentService.updateComment(member, commentId, commentUpdateRequest); + return ResponseEntity.ok().build(); + } + + @DeleteMapping + public ResponseEntity deleteComment( + @RequestParam @Min(value = 1L, message = "λŒ“κΈ€ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long commentId, + @LoginMember Member member + ) { + commentService.deleteComment(commentId, member); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java b/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java new file mode 100644 index 000000000..3aa7bf733 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java @@ -0,0 +1,81 @@ +package com.staccato.comment.controller.docs; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; + +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Comment", description = "Comment API") +public interface CommentControllerDocs { + @Operation(summary = "λŒ“κΈ€ 생성", description = "λŒ“κΈ€μ„ μƒμ„±ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "λŒ“κΈ€ 생성 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 λ•Œ + + (2) μŠ€νƒ€μΉ΄ν† λ₯Ό μ„ νƒν•˜μ§€ μ•Šμ•˜μ„ λ•Œ + + (3) μš”μ²­ν•œ μŠ€νƒ€μΉ΄ν† λ₯Ό 찾을 수 없을 λ•Œ + + (4) λŒ“κΈ€ λ‚΄μš©μ΄ 곡백 λΏμ΄κ±°λ‚˜ 없을 λ•Œ + + (5) λŒ“κΈ€μ΄ 곡백 포함 500자 초과일 λ•Œ + """, + responseCode = "400") + }) + ResponseEntity createComment( + @Parameter(hidden = true) Member member, + @Parameter(description = "λŒ“κΈ€ 생성 μ‹œ μš”κ΅¬ ν˜•μ‹") @Valid CommentRequest commentRequest); + + @Operation(summary = "λŒ“κΈ€ 쑰회", description = "μŠ€νƒ€μΉ΄ν† μ— μ†ν•œ λͺ¨λ“  λŒ“κΈ€μ„ 생성 순으둜 μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "λŒ“κΈ€ 쑰회 성곡", responseCode = "200"), + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 λ•Œ λ°œμƒ", responseCode = "400"), + }) + ResponseEntity readCommentsByMomentId( + @Parameter(hidden = true) Member member, + @Parameter(description = "λŒ“κΈ€μ΄ μ†ν•œ μŠ€νƒ€μΉ΄ν†  μ‹λ³„μž", example = "1") @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId); + + @Operation(summary = "λŒ“κΈ€ μˆ˜μ •", description = "λŒ“κΈ€μ„ μˆ˜μ •ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "λŒ“κΈ€ μˆ˜μ • 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) λŒ“κΈ€ μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 λ•Œ + + (2) μš”μ²­ν•œ λŒ“κΈ€μ„ 찾을 수 없을 λ•Œ + + (3) λŒ“κΈ€ λ‚΄μš©μ΄ 곡백 λΏμ΄κ±°λ‚˜ 없을 λ•Œ + + (4) λŒ“κΈ€μ΄ 곡백 포함 500자 초과일 λ•Œ + """, + responseCode = "400") + }) + ResponseEntity updateComment( + @Parameter(hidden = true) Member member, + @Parameter(description = "λŒ“κΈ€ μ‹λ³„μž", example = "1") @Min(value = 1L, message = "λŒ“κΈ€ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long commentId, + @Parameter(description = "λŒ“κΈ€ μˆ˜μ • μ‹œ μš”κ΅¬ ν˜•μ‹") @Valid CommentUpdateRequest commentUpdateRequest); + + @Operation(summary = "λŒ“κΈ€ μ‚­μ œ", description = "λŒ“κΈ€μ„ μ‚­μ œν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "λŒ“κΈ€ μ‚­μ œ 성곡", responseCode = "200"), + @ApiResponse(description = "λŒ“κΈ€ μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 μ‹œ λŒ“κΈ€ μ‚­μ œ μ‹€νŒ¨", responseCode = "400") + }) + ResponseEntity deleteComment( + @Parameter(description = "λŒ“κΈ€ μ‹λ³„μž", example = "1") @Min(value = 1L, message = "λŒ“κΈ€ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long commentId, + @Parameter(hidden = true) Member member); +} diff --git a/backend/src/main/java/com/staccato/comment/domain/Comment.java b/backend/src/main/java/com/staccato/comment/domain/Comment.java new file mode 100644 index 000000000..ede448994 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/domain/Comment.java @@ -0,0 +1,63 @@ +package com.staccato.comment.domain; + +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import com.staccato.config.domain.BaseEntity; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Table(indexes = { + @Index(name = "idx_moment_id", columnList = "moment_id") +}) +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moment_id", nullable = false) + private Moment moment; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + public Comment(@NonNull String content, @NotNull Moment moment, @NonNull Member member) { + this.content = content; + this.moment = moment; + this.member = member; + moment.addComment(this); + } + + public void changeContent(String content) { + this.content = content; + } + + public boolean isNotOwnedBy(Member member) { + return !Objects.equals(this.member, member); + } +} diff --git a/backend/src/main/java/com/staccato/comment/repository/CommentRepository.java b/backend/src/main/java/com/staccato/comment/repository/CommentRepository.java new file mode 100644 index 000000000..a9802336a --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/repository/CommentRepository.java @@ -0,0 +1,11 @@ +package com.staccato.comment.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.comment.domain.Comment; + +public interface CommentRepository extends JpaRepository { + List findAllByMomentIdOrderByCreatedAtAsc(long momentId); +} diff --git a/backend/src/main/java/com/staccato/comment/service/CommentService.java b/backend/src/main/java/com/staccato/comment/service/CommentService.java new file mode 100644 index 000000000..cc5553be9 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/CommentService.java @@ -0,0 +1,82 @@ +package com.staccato.comment.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.comment.domain.Comment; +import com.staccato.comment.repository.CommentRepository; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final MomentRepository momentRepository; + + @Transactional + public long createComment(CommentRequest commentRequest, Member member) { + Moment moment = getMoment(commentRequest.momentId()); + validateOwner(moment.getMemory(), member); + Comment comment = commentRequest.toComment(moment, member); + + return commentRepository.save(comment).getId(); + } + + public CommentResponses readAllCommentsByMomentId(Member member, Long momentId) { + Moment moment = getMoment(momentId); + validateOwner(moment.getMemory(), member); + List comments = commentRepository.findAllByMomentIdOrderByCreatedAtAsc(momentId); + + return CommentResponses.from(comments); + } + + @Transactional + public void updateComment(Member member, Long commentId, CommentUpdateRequest commentUpdateRequest) { + Comment comment = getComment(commentId); + validateCommentOwner(comment, member); + comment.changeContent(commentUpdateRequest.content()); + } + + private Moment getMoment(long momentId) { + return momentRepository.findById(momentId) + .orElseThrow(() -> new StaccatoException("μš”μ²­ν•˜μ‹  μŠ€νƒ€μΉ΄ν† λ₯Ό 찾을 수 μ—†μ–΄μš”.")); + } + + private Comment getComment(long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new StaccatoException("μš”μ²­ν•˜μ‹  λŒ“κΈ€μ„ 찾을 수 μ—†μ–΄μš”.")); + } + + private void validateOwner(Memory memory, Member member) { + if (memory.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } + + private void validateCommentOwner(Comment comment, Member member) { + if (comment.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } + + @Transactional + public void deleteComment(long commentId, Member member) { + commentRepository.findById(commentId).ifPresent(comment -> { + validateCommentOwner(comment, member); + commentRepository.deleteById(commentId); + }); + } +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java new file mode 100644 index 000000000..69c4a50c6 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentRequest.java @@ -0,0 +1,32 @@ +package com.staccato.comment.service.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import com.staccato.comment.domain.Comment; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "λŒ“κΈ€ 생성 μ‹œ μš”μ²­ ν˜•μ‹μž…λ‹ˆλ‹€.") +public record CommentRequest( + @Schema(example = "1") + @NotNull(message = "μŠ€νƒ€μΉ΄ν† λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.") + @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") + Long momentId, + @Schema(example = "μ˜ˆμ‹œ λŒ“κΈ€ λ‚΄μš©") + @NotBlank(message = "λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(max = 500, message = "λŒ“κΈ€μ€ 곡백 포함 500자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String content +) { + public Comment toComment(Moment moment, Member member) { + return Comment.builder() + .content(content) + .moment(moment) + .member(member) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java new file mode 100644 index 000000000..98ed05f8f --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/request/CommentUpdateRequest.java @@ -0,0 +1,15 @@ +package com.staccato.comment.service.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "λŒ“κΈ€ μˆ˜μ • μ‹œ μš”μ²­ ν˜•μ‹μž…λ‹ˆλ‹€.") +public record CommentUpdateRequest( + @Schema(example = "μ˜ˆμ‹œ μˆ˜μ •λœ λŒ“κΈ€ λ‚΄μš©") + @NotBlank(message = "λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(max = 500, message = "λŒ“κΈ€μ€ 곡백 포함 500자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String content +) { +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java new file mode 100644 index 000000000..e5e73e20d --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponse.java @@ -0,0 +1,32 @@ +package com.staccato.comment.service.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.comment.domain.Comment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν† μ— λŒ€ν•΄ ν•¨κ»˜ ν•œ μΉœκ΅¬μ™€ λ‚˜λˆˆ λŒ€ν™” 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record CommentResponse( + @Schema(example = "1") + Long commentId, + @Schema(example = "1") + Long memberId, + @Schema(example = "μΉ΄κ³ ") + String nickname, + @Schema(example = "https://example.com/images/kargo.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memberImageUrl, + @Schema(example = "즐거운 μΆ”μ–΅") + @JsonInclude(JsonInclude.Include.NON_NULL) + String content +) { + public CommentResponse(Comment comment) { + this( + comment.getId(), + comment.getMember().getId(), + comment.getMember().getNickname().getNickname(), + comment.getMember().getImageUrl(), + comment.getContent() + ); + } +} diff --git a/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java new file mode 100644 index 000000000..f8dc239c1 --- /dev/null +++ b/backend/src/main/java/com/staccato/comment/service/dto/response/CommentResponses.java @@ -0,0 +1,15 @@ +package com.staccato.comment.service.dto.response; + +import java.util.List; + +import com.staccato.comment.domain.Comment; + +public record CommentResponses(List comments) { + public static CommentResponses from(List comments) { + return new CommentResponses( + comments.stream() + .map(CommentResponse::new) + .toList() + ); + } +} diff --git a/backend/src/main/java/com/staccato/config/JpaAuditingConfig.java b/backend/src/main/java/com/staccato/config/JpaAuditingConfig.java new file mode 100644 index 000000000..0c609456f --- /dev/null +++ b/backend/src/main/java/com/staccato/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.staccato.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/backend/src/main/java/com/staccato/config/OpenApiConfig.java b/backend/src/main/java/com/staccato/config/OpenApiConfig.java new file mode 100644 index 000000000..17cf24004 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/OpenApiConfig.java @@ -0,0 +1,38 @@ +package com.staccato.config; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class OpenApiConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .servers(Arrays.asList( + new Server().url("https://stage.staccato.kr").description("Stage Server URL"), + new Server().url("https://dev.staccato.kr").description("Dev Server URL"), + new Server().url("http://localhost:8080").description("Local Server URL") + )) + .addSecurityItem(new SecurityRequirement().addList("Auth")) + .components(attachBearerAuthScheme()); + } + + private Components attachBearerAuthScheme() { + return new Components().addSecuritySchemes("Auth", + new SecurityScheme() + .name("Authorization") + .type(Type.APIKEY) + .in(In.HEADER) + .description("Enter your token in the Authorization header")); + } +} diff --git a/backend/src/main/java/com/staccato/config/WebMvcConfig.java b/backend/src/main/java/com/staccato/config/WebMvcConfig.java new file mode 100644 index 000000000..d2b9a4c1e --- /dev/null +++ b/backend/src/main/java/com/staccato/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.staccato.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.staccato.config.auth.LoginMemberArgumentResolver; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + } +} diff --git a/backend/src/main/java/com/staccato/config/auth/AdminProperties.java b/backend/src/main/java/com/staccato/config/auth/AdminProperties.java new file mode 100644 index 000000000..22dc27337 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/AdminProperties.java @@ -0,0 +1,8 @@ +package com.staccato.config.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "security.admin") +public record AdminProperties(String key, String token) { +} + diff --git a/backend/src/main/java/com/staccato/config/auth/LoginMember.java b/backend/src/main/java/com/staccato/config/auth/LoginMember.java new file mode 100644 index 000000000..f219d7ae6 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/LoginMember.java @@ -0,0 +1,11 @@ +package com.staccato.config.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 LoginMember { +} diff --git a/backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java b/backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..2cd872bc7 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,33 @@ +package com.staccato.config.auth; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.staccato.auth.service.AuthService; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + return authService.extractFromToken(token); + } +} diff --git a/backend/src/main/java/com/staccato/config/auth/TokenProperties.java b/backend/src/main/java/com/staccato/config/auth/TokenProperties.java new file mode 100644 index 000000000..cba9995de --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/TokenProperties.java @@ -0,0 +1,7 @@ +package com.staccato.config.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "security.jwt.token") +public record TokenProperties(String secretKey) { +} diff --git a/backend/src/main/java/com/staccato/config/auth/TokenProvider.java b/backend/src/main/java/com/staccato/config/auth/TokenProvider.java new file mode 100644 index 000000000..8da818a38 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/auth/TokenProvider.java @@ -0,0 +1,45 @@ +package com.staccato.config.auth; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import com.staccato.exception.UnauthorizedException; +import com.staccato.member.domain.Member; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(TokenProperties.class) +public class TokenProvider { + private final TokenProperties tokenProperties; + + public String create(Member member) { + return Jwts.builder() + .claim("id", member.getId()) + .claim("nickname", member.getNickname().getNickname()) + .claim("createdAt", member.getCreatedAt().toString()) + .signWith(SignatureAlgorithm.HS256, tokenProperties.secretKey().getBytes()) + .compact(); + } + + public long extractMemberId(String token) { + Claims claims = getPayload(token); + return claims.get("id", Long.class); + } + + public Claims getPayload(String token) { + try { + return Jwts.parser() + .setSigningKey(tokenProperties.secretKey().getBytes()) + .parseClaimsJws(token) + .getBody(); + } catch (JwtException | IllegalArgumentException e) { + throw new UnauthorizedException(); + } + } +} diff --git a/backend/src/main/java/com/staccato/config/domain/BaseEntity.java b/backend/src/main/java/com/staccato/config/domain/BaseEntity.java new file mode 100644 index 000000000..a623e66c2 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/domain/BaseEntity.java @@ -0,0 +1,22 @@ +package com.staccato.config.domain; + +import java.time.LocalDateTime; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity { + @CreatedDate + private LocalDateTime createdAt; + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/staccato/config/log/LogForm.java b/backend/src/main/java/com/staccato/config/log/LogForm.java new file mode 100644 index 000000000..72e88fe0c --- /dev/null +++ b/backend/src/main/java/com/staccato/config/log/LogForm.java @@ -0,0 +1,33 @@ +package com.staccato.config.log; + +public class LogForm { + private static final String DELIMITER = ",\n "; + private static final String INDENT = " "; + + public static final String REQUEST_LOGGING_FORM = "\n{\n" + + INDENT + "\"httpStatus\": \"{}\"" + DELIMITER + + "\"httpMethod\": \"{}\"" + DELIMITER + + "\"requestUri\": \"{}\"" + DELIMITER + + "\"tokenExists\": \"{}\"" + DELIMITER + + "\"processingTimeMs\": \"{}\"\n" + + "}"; + + public static final String LOGIN_MEMBER_FORM = "\n{\n" + + INDENT + "\"loginMemberId\": \"{}\"" + DELIMITER + + "\"loginMemberNickname\": \"{}\"\n" + + "}"; + + public static final String CUSTOM_EXCEPTION_LOGGING_FORM = "\n{\n" + + INDENT + "\"exceptionResponse\": \"{}\"\n" + + "}"; + + public static final String EXCEPTION_LOGGING_FORM = "\n{\n" + + INDENT + "\"exceptionResponse\": \"{}\"\n" + DELIMITER + + "\"exceptionMessage\": \"{}\"\n" + + "}"; + + public static final String ERROR_LOGGING_FORM = "\n{\n" + + INDENT + "\"exceptionResponse\": \"{}\"" + DELIMITER + + "\"exceptionMessage\": \"{}\"\n" + + "}"; +} diff --git a/backend/src/main/java/com/staccato/config/log/LoggingFilter.java b/backend/src/main/java/com/staccato/config/log/LoggingFilter.java new file mode 100644 index 000000000..2b15297b0 --- /dev/null +++ b/backend/src/main/java/com/staccato/config/log/LoggingFilter.java @@ -0,0 +1,58 @@ +package com.staccato.config.log; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StopWatch; +import org.springframework.web.filter.OncePerRequestFilter; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class LoggingFilter extends OncePerRequestFilter { + private static final String IDENTIFIER = "request_id"; + private static final List WHITE_LIST = List.of("/h2-console/**", "/favicon/**", "/swagger-ui/**", "/v3/api-docs/**"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + MDC.put(IDENTIFIER, UUID.randomUUID().toString()); + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + try { + filterChain.doFilter(request, response); + } finally { + stopWatch.stop(); + log.info(LogForm.REQUEST_LOGGING_FORM, + response.getStatus(), + request.getMethod(), + request.getRequestURI(), + tokenExists(token), + stopWatch.getTotalTimeMillis()); + MDC.clear(); + } + } + + private boolean tokenExists(String token) { + return !(Objects.isNull(token) || token.isBlank()); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String requestURI = request.getRequestURI(); + AntPathMatcher antPathMatcher = new AntPathMatcher(); + return WHITE_LIST.stream().anyMatch(path -> antPathMatcher.match(path, requestURI)); + } +} diff --git a/backend/src/main/java/com/staccato/exception/ExceptionResponse.java b/backend/src/main/java/com/staccato/exception/ExceptionResponse.java new file mode 100644 index 000000000..dcaa51acf --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/ExceptionResponse.java @@ -0,0 +1,10 @@ +package com.staccato.exception; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μ˜ˆμ™Έμ— λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record ExceptionResponse( + String status, + String message +) { +} diff --git a/backend/src/main/java/com/staccato/exception/ForbiddenException.java b/backend/src/main/java/com/staccato/exception/ForbiddenException.java new file mode 100644 index 000000000..8c3c56fa9 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/ForbiddenException.java @@ -0,0 +1,19 @@ +package com.staccato.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException() { + super("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + public ForbiddenException(final String message) { + super(message); + } + + public ForbiddenException(final String message, final Throwable cause) { + super(message, cause); + } + + public ForbiddenException(final Throwable cause) { + super(cause); + } +} diff --git a/backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..a5d9866ce --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/GlobalExceptionHandler.java @@ -0,0 +1,124 @@ +package com.staccato.exception; + +import java.util.Optional; + +import jakarta.validation.ConstraintViolationException; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +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.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +import com.staccato.config.log.LogForm; + +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + String exceptionMessage = "μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 쿼리 슀트링 ν˜•μ‹μž…λ‹ˆλ‹€."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String exceptionMessage = Optional.ofNullable(e.getBindingResult().getFieldError()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .orElse("μš”μ²­ ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String exceptionMessage = e.getConstraintViolations() + .iterator() + .next() + .getMessage(); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + String exceptionMessage = "μš”μ²­ 본문을 읽을 수 μ—†μŠ΅λ‹ˆλ‹€. μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ 데이터λ₯Ό μ œκ³΅ν•΄μ£Όμ„Έμš”."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(S3Exception.class) + public ResponseEntity handleS3Exception(S3Exception e) { + String exceptionMessage = "이미지 μ²˜λ¦¬μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingServletRequestPartException(MissingServletRequestPartException e) { + String exceptionMessage = "μš”μ²­λœ νŒŒνŠΈκ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ˜¬λ°”λ₯Έ 데이터λ₯Ό μ œκ³΅ν•΄μ£Όμ„Έμš”."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(MultipartException.class) + public ResponseEntity handleMultipartException(MultipartException e) { + String exceptionMessage = "20MB μ΄ν•˜μ˜ 사진을 μ—…λ‘œλ“œν•΄ μ£Όμ„Έμš”."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.PAYLOAD_TOO_LARGE.toString(), exceptionMessage); + log.warn(LogForm.EXCEPTION_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(StaccatoException.class) + @ApiResponse(responseCode = "400") + public ResponseEntity handleStaccatoException(StaccatoException e) { + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), e.getMessage()); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.badRequest().body(exceptionResponse); + } + + @ExceptionHandler(UnauthorizedException.class) + @ApiResponse(description = "μ‚¬μš©μž 인증 μ‹€νŒ¨", responseCode = "401") + public ResponseEntity handleUnauthorizedException(UnauthorizedException e) { + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.UNAUTHORIZED.toString(), e.getMessage()); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(exceptionResponse); + } + + @ExceptionHandler(ForbiddenException.class) + @ApiResponse(description = "μ‚¬μš©μžκ°€ κΆŒν•œμ„ 가지고 μžˆμ§€ μ•Šμ€ μž‘μ—…μ„ μ‹œλ„ μ‹œ λ°œμƒ", responseCode = "403") + public ResponseEntity handleForbiddenException(ForbiddenException e) { + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.FORBIDDEN.toString(), e.getMessage()); + log.warn(LogForm.CUSTOM_EXCEPTION_LOGGING_FORM, exceptionResponse); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(exceptionResponse); + } + + @ExceptionHandler(RuntimeException.class) + @ApiResponse(responseCode = "500") + public ResponseEntity handleInternalServerErrorException(RuntimeException e) { + String exceptionMessage = "예기치 λͺ»ν•œ μ„œλ²„ 였λ₯˜μž…λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.INTERNAL_SERVER_ERROR.toString(), exceptionMessage); + log.error(LogForm.ERROR_LOGGING_FORM, exceptionResponse, e.getMessage()); + return ResponseEntity.internalServerError().body(exceptionResponse); + } +} diff --git a/backend/src/main/java/com/staccato/exception/StaccatoException.java b/backend/src/main/java/com/staccato/exception/StaccatoException.java new file mode 100644 index 000000000..4fe740672 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/StaccatoException.java @@ -0,0 +1,19 @@ +package com.staccato.exception; + +public class StaccatoException extends RuntimeException { + public StaccatoException() { + super(); + } + + public StaccatoException(String message) { + super(message); + } + + public StaccatoException(String message, Throwable cause) { + super(message, cause); + } + + public StaccatoException(Throwable cause) { + super(cause); + } +} diff --git a/backend/src/main/java/com/staccato/exception/UnauthorizedException.java b/backend/src/main/java/com/staccato/exception/UnauthorizedException.java new file mode 100644 index 000000000..3b0078662 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/UnauthorizedException.java @@ -0,0 +1,19 @@ +package com.staccato.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException() { + super("μΈμ¦λ˜μ§€ μ•Šμ€ μ‚¬μš©μžμž…λ‹ˆλ‹€."); + } + + public UnauthorizedException(final String message) { + super(message); + } + + public UnauthorizedException(final String message, final Throwable cause) { + super(message, cause); + } + + public UnauthorizedException(final Throwable cause) { + super(cause); + } +} diff --git a/backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java b/backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java new file mode 100644 index 000000000..e7043a210 --- /dev/null +++ b/backend/src/main/java/com/staccato/exception/validation/ValidationSteps.java @@ -0,0 +1,15 @@ +package com.staccato.exception.validation; + +import jakarta.validation.GroupSequence; + +public class ValidationSteps { + public interface FirstStep { + } + + public interface SecondStep { + } + + @GroupSequence({FirstStep.class, SecondStep.class}) + public interface ValidationSequence { + } +} diff --git a/backend/src/main/java/com/staccato/image/controller/ImageController.java b/backend/src/main/java/com/staccato/image/controller/ImageController.java new file mode 100644 index 000000000..aeceb9216 --- /dev/null +++ b/backend/src/main/java/com/staccato/image/controller/ImageController.java @@ -0,0 +1,35 @@ +package com.staccato.image.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.staccato.config.auth.LoginMember; +import com.staccato.image.controller.docs.ImageControllerDocs; +import com.staccato.image.service.ImageService; +import com.staccato.image.service.dto.ImageUrlResponse; +import com.staccato.member.domain.Member; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/images") +@RequiredArgsConstructor +public class ImageController implements ImageControllerDocs { + private final ImageService imageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @RequestPart(value = "imageFile") MultipartFile image, + @LoginMember Member member + ) { + ImageUrlResponse imageUrlResponse = imageService.uploadImage(image); + + return ResponseEntity.status(HttpStatus.CREATED).body(imageUrlResponse); + } +} diff --git a/backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java b/backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java new file mode 100644 index 000000000..fbbb9455a --- /dev/null +++ b/backend/src/main/java/com/staccato/image/controller/docs/ImageControllerDocs.java @@ -0,0 +1,35 @@ +package com.staccato.image.controller.docs; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.staccato.config.auth.LoginMember; +import com.staccato.image.service.dto.ImageUrlResponse; +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Image", description = "Image API") +public interface ImageControllerDocs { + + @Operation(summary = "이미지 μ—…λ‘œλ“œ", description = "이미지λ₯Ό μ—…λ‘œλ“œν•˜κ³  S3 url을 κ°€μ Έμ˜΅λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "이미지 μ—…λ‘œλ“œ 성곡", responseCode = "201"), + @ApiResponse(description = "μ „μ†‘λœ 파일이 μ†μƒλ˜μ—ˆκ±°λ‚˜ μ§€μ›λ˜μ§€ μ•ŠλŠ” ν˜•μ‹μΌ λ•Œ", responseCode = "400"), + @ApiResponse(description = "μš”μ²­λœ νŒŒνŠΈκ°€ λˆ„λ½λ˜μ—ˆμ„ λ•Œ", responseCode = "400"), + @ApiResponse(description = "20MB 초과의 사진을 μ—…λ‘œλ“œ ν•˜λ €κ³  ν•  λ•Œ", responseCode = "413") + }) + ResponseEntity uploadImage( + @Parameter(description = "μ—…λ‘œλ“œν•  이미지 파일 (PNG, JPG, JPEG, WEBP) ν˜•μ‹ 지원, μ΅œλŒ€ 20MB", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "imageFile") MultipartFile image, + @Parameter(hidden = true) @LoginMember Member member); +} diff --git a/backend/src/main/java/com/staccato/image/domain/ImageExtension.java b/backend/src/main/java/com/staccato/image/domain/ImageExtension.java new file mode 100644 index 000000000..b578154b0 --- /dev/null +++ b/backend/src/main/java/com/staccato/image/domain/ImageExtension.java @@ -0,0 +1,26 @@ +package com.staccato.image.domain; + +import java.util.Arrays; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageExtension { + PNG(".png", "image/png"), + JPG(".jpg", "image/jpg"), + JPEG(".jpeg", "image/jpeg"), + WEBP(".webp", "image/webp"); + + private final String extension; + private final String contentType; + + public static String getContentType(String extension) { + return Arrays.stream(ImageExtension.values()) + .filter(imageExtension -> imageExtension.getExtension().equalsIgnoreCase(extension)) + .map(ImageExtension::getContentType) + .findFirst() + .orElse("application/octet-stream"); + } +} diff --git a/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java b/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java new file mode 100644 index 000000000..ded00e41b --- /dev/null +++ b/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java @@ -0,0 +1,54 @@ +package com.staccato.image.infrastructure; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Component +public class S3ObjectClient { + private final S3Client s3Client; + private final String bucketName; + private final String endPoint; + private final String cloudFrontEndPoint; + + public S3ObjectClient( + @Value("${cloud.aws.s3.bucket}") String bucketName, + @Value("${cloud.aws.s3.endpoint}") String endPoint, + @Value("${cloud.aws.cloudfront.endpoint}") String cloudFrontEndPoint + ) { + this.s3Client = software.amazon.awssdk.services.s3.S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(InstanceProfileCredentialsProvider.create()) + .build(); + this.bucketName = bucketName; + this.endPoint = endPoint; + this.cloudFrontEndPoint = cloudFrontEndPoint; + } + + public void putS3Object(String objectKey, String contentType, byte[] imageBytes) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentType(contentType) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(imageBytes)); + } + + public String getUrl(String keyName) { + GetUrlRequest request = GetUrlRequest.builder() + .bucket(bucketName) + .key(keyName) + .build(); + + String url = s3Client.utilities().getUrl(request).toString(); + + return url.replace(endPoint, cloudFrontEndPoint); + } +} diff --git a/backend/src/main/java/com/staccato/image/service/ImageService.java b/backend/src/main/java/com/staccato/image/service/ImageService.java new file mode 100644 index 000000000..873d594af --- /dev/null +++ b/backend/src/main/java/com/staccato/image/service/ImageService.java @@ -0,0 +1,54 @@ +package com.staccato.image.service; + +import java.io.IOException; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.staccato.exception.StaccatoException; +import com.staccato.image.domain.ImageExtension; +import com.staccato.image.infrastructure.S3ObjectClient; +import com.staccato.image.service.dto.ImageUrlResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ImageService { + private static final String TEAM_FOLDER_NAME = "staccato/"; + + @Value("${image.folder.name}") + private String imageFolderName; + + private final S3ObjectClient s3ObjectClient; + + public ImageUrlResponse uploadImage(MultipartFile image) { + String imageExtension = getImageExtension(image); + String key = TEAM_FOLDER_NAME + imageFolderName + UUID.randomUUID() + imageExtension; + String contentType = ImageExtension.getContentType(imageExtension); + byte[] imageBytes = getImageBytes(image); + + s3ObjectClient.putS3Object(key, contentType, imageBytes); + String imageUrl = s3ObjectClient.getUrl(key); + + return new ImageUrlResponse(imageUrl); + } + + private String getImageExtension(MultipartFile image) { + String imageName = image.getOriginalFilename(); + if (imageName == null || !imageName.contains(".")) { + return ""; + } + return imageName.substring(imageName.lastIndexOf('.')); + } + + private byte[] getImageBytes(MultipartFile image) { + try { + return image.getBytes(); + } catch (IOException e) { + throw new StaccatoException("μ „μ†‘λœ 파일이 μ†μƒλ˜μ—ˆκ±°λ‚˜ μ§€μ›λ˜μ§€ μ•ŠλŠ” ν˜•μ‹μž…λ‹ˆλ‹€."); + } + } +} diff --git a/backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java b/backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java new file mode 100644 index 000000000..a6290269e --- /dev/null +++ b/backend/src/main/java/com/staccato/image/service/dto/ImageUrlResponse.java @@ -0,0 +1,10 @@ +package com.staccato.image.service.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "이미지 μ—…λ‘œλ“œλ₯Ό ν–ˆμ„ λ•Œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record ImageUrlResponse( + @Schema(example = "https://d1234abcdefg.cloudfront.net/staccato/image/abcdefg.jpg") + String imageUrl +) { +} diff --git a/backend/src/main/java/com/staccato/member/domain/Member.java b/backend/src/main/java/com/staccato/member/domain/Member.java new file mode 100644 index 000000000..2a4997c2a --- /dev/null +++ b/backend/src/main/java/com/staccato/member/domain/Member.java @@ -0,0 +1,45 @@ +package com.staccato.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.staccato.config.domain.BaseEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE member SET is_deleted = true WHERE id = ?") +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@SQLRestriction("is_deleted = false") +public class Member extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + @Column(nullable = false, unique = true) + @Embedded + private Nickname nickname; + @Column(columnDefinition = "TEXT") + private String imageUrl; + private Boolean isDeleted = false; + + @Builder + public Member(@NonNull String nickname, String imageUrl) { + this.nickname = new Nickname(nickname); + this.imageUrl = imageUrl; + } +} diff --git a/backend/src/main/java/com/staccato/member/domain/Nickname.java b/backend/src/main/java/com/staccato/member/domain/Nickname.java new file mode 100644 index 000000000..a4a84a5af --- /dev/null +++ b/backend/src/main/java/com/staccato/member/domain/Nickname.java @@ -0,0 +1,36 @@ +package com.staccato.member.domain; + +import java.util.regex.Pattern; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import com.staccato.exception.StaccatoException; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Embeddable +@EqualsAndHashCode +public class Nickname { + private static final Pattern NICKNAME_REGEX = Pattern.compile("^[γ„±-γ…Žγ…-γ…£κ°€-힣0-9a-zA-Z._]+$"); + private static final int MAX_LENGTH = 20; + + @Column(nullable = false, length = MAX_LENGTH) + private String nickname; + + public Nickname(String nickname) { + String trimmedNickname = nickname.trim(); + validateRegex(trimmedNickname); + this.nickname = trimmedNickname; + } + + private static void validateRegex(String nickname) { + if (!NICKNAME_REGEX.matcher(nickname).matches()) { + throw new StaccatoException("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ λ‹‰λ„€μž„ ν˜•μ‹μž…λ‹ˆλ‹€."); + } + } +} diff --git a/backend/src/main/java/com/staccato/member/repository/MemberRepository.java b/backend/src/main/java/com/staccato/member/repository/MemberRepository.java new file mode 100644 index 000000000..04c2ae2d2 --- /dev/null +++ b/backend/src/main/java/com/staccato/member/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package com.staccato.member.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.member.domain.Member; +import com.staccato.member.domain.Nickname; + +public interface MemberRepository extends JpaRepository { + Optional findByIdAndIsDeletedIsFalse(long memberId); + + boolean existsByNickname(Nickname nickname); +} diff --git a/backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java b/backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java new file mode 100644 index 000000000..676f9ebe2 --- /dev/null +++ b/backend/src/main/java/com/staccato/member/service/dto/response/MemberResponse.java @@ -0,0 +1,21 @@ +package com.staccato.member.service.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.member.domain.Member; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μ—¬λŸ¬ νšŒμ› 정보λ₯Ό ν‘œμ‹œν•  λ•Œ ν•„μš”ν•œ 정보에 λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemberResponse( + @Schema(example = "1") + Long memberId, + @Schema(example = "staccato") + String nickname, + @Schema(example = "https://example.com/members/profile.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memberImageUrl +) { + public MemberResponse(Member member) { + this(member.getId(), member.getNickname().getNickname(), member.getImageUrl()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/controller/MemoryController.java b/backend/src/main/java/com/staccato/memory/controller/MemoryController.java new file mode 100644 index 000000000..76c74c4be --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/MemoryController.java @@ -0,0 +1,88 @@ +package com.staccato.memory.controller; + +import java.net.URI; +import java.time.LocalDate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.config.auth.LoginMember; +import com.staccato.member.domain.Member; +import com.staccato.memory.controller.docs.MemoryControllerDocs; +import com.staccato.memory.service.MemoryService; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryResponses; +import com.staccato.memory.service.dto.response.MemoryNameResponses; + +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequestMapping("/memories") +@RequiredArgsConstructor +public class MemoryController implements MemoryControllerDocs { + private final MemoryService memoryService; + + @PostMapping + public ResponseEntity createMemory( + @Valid @RequestBody MemoryRequest memoryRequest, + @LoginMember Member member + ) { + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest, member); + return ResponseEntity.created(URI.create("/memories/" + memoryIdResponse.memoryId())).body(memoryIdResponse); + } + + @GetMapping + public ResponseEntity readAllMemories(@LoginMember Member member) { + MemoryResponses memoryResponses = memoryService.readAllMemories(member); + return ResponseEntity.ok(memoryResponses); + } + + @GetMapping("/candidates") + public ResponseEntity readAllCandidateMemories( + @LoginMember Member member, + @RequestParam(value = "currentDate") LocalDate currentDate + ) { + MemoryNameResponses memoryNameResponses = memoryService.readAllMemoriesIncludingDate(member, currentDate); + return ResponseEntity.ok(memoryNameResponses); + } + + @GetMapping("/{memoryId}") + public ResponseEntity readMemory( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long memoryId) { + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(memoryId, member); + return ResponseEntity.ok(memoryDetailResponse); + } + + @PutMapping(path = "/{memoryId}") + public ResponseEntity updateMemory( + @PathVariable @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long memoryId, + @Valid @RequestBody MemoryRequest memoryRequest, + @LoginMember Member member) { + memoryService.updateMemory(memoryRequest, memoryId, member); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{memoryId}") + public ResponseEntity deleteMemory( + @PathVariable @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long memoryId, + @LoginMember Member member) { + memoryService.deleteMemory(memoryId, member); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java b/backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java new file mode 100644 index 000000000..00f610da2 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/docs/MemoryControllerDocs.java @@ -0,0 +1,115 @@ +package com.staccato.memory.controller.docs; + +import java.time.LocalDate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; + +import com.staccato.member.domain.Member; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Memory", description = "Memory API") +public interface MemoryControllerDocs { + @Operation(summary = "μΆ”μ–΅ 생성", description = "μΆ”μ–΅(썸넀일, 제λͺ©, λ‚΄μš©, κΈ°κ°„)을 μƒμ„±ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μΆ”μ–΅ 생성 성곡", responseCode = "201"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) ν•„μˆ˜ κ°’(μΆ”μ–΅ 제λͺ©, κΈ°κ°„)이 λˆ„λ½λ˜μ—ˆμ„ λ•Œ + + (2) λ‚ μ§œ ν˜•μ‹(yyyy-MM-dd)이 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (3) 제λͺ©μ΄ 곡백 포함 30자λ₯Ό μ΄ˆκ³Όν–ˆμ„ λ•Œ + + (4) λ‚΄μš©μ΄ 곡백 포함 500자λ₯Ό μ΄ˆκ³Όν–ˆμ„ λ•Œ + + (5) κΈ°κ°„ 섀정이 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (6) 이미 μ‘΄μž¬ν•˜λŠ” μΆ”μ–΅ 이름일 λ•Œ + """, + responseCode = "400") + }) + ResponseEntity createMemory( + @Parameter(required = true) @Valid MemoryRequest memoryRequest, + @Parameter(hidden = true) Member member); + + @Operation(summary = "μΆ”μ–΅ λͺ©λ‘ 쑰회", description = "μ‚¬μš©μžμ˜ λͺ¨λ“  μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponse(description = "μΆ”μ–΅ λͺ©λ‘ 쑰회 성곡", responseCode = "200") + ResponseEntity readAllMemories(@Parameter(hidden = true) Member member); + + @Operation(summary = "νŠΉμ • λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜λŠ” μ‚¬μš©μžμ˜ λͺ¨λ“  μΆ”μ–΅ λͺ©λ‘ 쑰회", description = "νŠΉμ • λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜λŠ” μ‚¬μš©μžμ˜ λͺ¨λ“  μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μΆ”μ–΅ λͺ©λ‘ 쑰회 성곡", responseCode = "200"), + @ApiResponse(description = "μž…λ ₯받은 ν˜„μž¬ λ‚ μ§œκ°€ μœ νš¨ν•˜μ§€ μ•Šμ„ λ•Œ λ°œμƒ", responseCode = "400") + }) + ResponseEntity readAllCandidateMemories( + @Parameter(hidden = true) Member member, + @Parameter(description = "ν˜„μž¬ λ‚ μ§œ", example = "2024-08-21") LocalDate currentDate); + + @Operation(summary = "μΆ”μ–΅ 쑰회", description = "μ‚¬μš©μžμ˜ 좔얡을 μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μΆ”μ–΅ 쑰회 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 좔얡을 μ‘°νšŒν•˜λ €κ³  ν–ˆμ„ λ•Œ + + (2) Path Variable ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ + """, + responseCode = "400") + }) + ResponseEntity readMemory( + @Parameter(hidden = true) Member member, + @Parameter(description = "μΆ”μ–΅ ID", example = "1") @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long memoryId); + + @Operation(summary = "μΆ”μ–΅ μˆ˜μ •", description = "μΆ”μ–΅ 정보(썸넀일, 제λͺ©, λ‚΄μš©, κΈ°κ°„)λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μΆ”μ–΅ μˆ˜μ • 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) ν•„μˆ˜ κ°’(μΆ”μ–΅ 제λͺ©, κΈ°κ°„)이 λˆ„λ½λ˜μ—ˆμ„ λ•Œ + + (2) λ‚ μ§œ ν˜•μ‹(yyyy-MM-dd)이 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (3) 제λͺ©μ΄ 곡백 포함 30자λ₯Ό μ΄ˆκ³Όν–ˆμ„ λ•Œ + + (4) λ‚΄μš©μ΄ 곡백 포함 500자λ₯Ό μ΄ˆκ³Όν–ˆμ„ λ•Œ + + (5) κΈ°κ°„ 섀정이 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (6) λ³€κ²½ν•˜λ €λŠ” μΆ”μ–΅ 기간이 이미 μ‘΄μž¬ν•˜λŠ” μŠ€νƒ€μΉ΄ν† λ₯Ό ν¬ν•¨ν•˜μ§€ μ•Šμ„ λ•Œ + + (7) μˆ˜μ •ν•˜λ €λŠ” 좔얡이 μ‘΄μž¬ν•˜μ§€ μ•Šμ„ λ•Œ + + (8) Path Variable ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ + """, + responseCode = "400") + }) + ResponseEntity updateMemory( + @Parameter(description = "μΆ”μ–΅ ID", example = "1") @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long memoryId, + @Parameter(required = true) @Valid MemoryRequest memoryRequest, + @Parameter(hidden = true) Member member); + + @Operation(summary = "μΆ”μ–΅ μ‚­μ œ", description = "μ‚¬μš©μžμ˜ 좔얡을 μ‚­μ œν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μΆ”μ–΅ μ‚­μ œ 성곡", responseCode = "200"), + @ApiResponse(description = "Path Variable ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ λ°œμƒ", responseCode = "400") + }) + ResponseEntity deleteMemory( + @Parameter(description = "μΆ”μ–΅ ID", example = "1") @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long memoryId, + @Parameter(hidden = true) Member member); +} diff --git a/backend/src/main/java/com/staccato/memory/domain/Memory.java b/backend/src/main/java/com/staccato/memory/domain/Memory.java new file mode 100644 index 000000000..08c9a7dfb --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/domain/Memory.java @@ -0,0 +1,102 @@ +package com.staccato.memory.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +import com.staccato.config.domain.BaseEntity; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Memory extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(columnDefinition = "TEXT") + private String thumbnailUrl; + @Column(nullable = false, length = 50) + private String title; + @Column(columnDefinition = "TEXT") + private String description; + @Column + @Embedded + private Term term; + @OneToMany(mappedBy = "memory", orphanRemoval = true, cascade = CascadeType.ALL) + private List memoryMembers = new ArrayList<>(); + + @Builder + public Memory(String thumbnailUrl, @NonNull String title, String description, LocalDate startAt, LocalDate endAt) { + this.thumbnailUrl = thumbnailUrl; + this.title = title.trim(); + this.description = description; + this.term = new Term(startAt, endAt); + } + + public void addMemoryMember(MemoryMember memoryMember) { + memoryMembers.add(memoryMember); + } + + public void addMemoryMember(Member member) { + MemoryMember memoryMember = MemoryMember.builder() + .memory(this) + .member(member) + .build(); + memoryMembers.add(memoryMember); + } + + public void update(Memory updatedMemory, List moments) { + validateDuration(updatedMemory, moments); + this.thumbnailUrl = updatedMemory.getThumbnailUrl(); + this.title = updatedMemory.getTitle(); + this.description = updatedMemory.getDescription(); + this.term = updatedMemory.getTerm(); + } + + private void validateDuration(Memory updatedMemory, List moments) { + moments.stream() + .filter(moment -> updatedMemory.isWithoutDuration(moment.getVisitedAt())) + .findAny() + .ifPresent(moment -> { + throw new StaccatoException("기간이 이미 μ‘΄μž¬ν•˜λŠ” μŠ€νƒ€μΉ΄ν† λ₯Ό ν¬ν•¨ν•˜μ§€ μ•Šμ•„μš”. λ‹€μ‹œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + }); + } + + public boolean isWithoutDuration(LocalDateTime date) { + return term.doesNotContain(date); + } + + public List getMates() { + return memoryMembers.stream() + .map(MemoryMember::getMember) + .toList(); + } + + public boolean isNotOwnedBy(Member member) { + return memoryMembers.stream() + .noneMatch(memoryMember -> memoryMember.isMember(member)); + } + + public boolean isNotSameTitle(String title) { + return !this.title.equals(title); + } +} diff --git a/backend/src/main/java/com/staccato/memory/domain/MemoryMember.java b/backend/src/main/java/com/staccato/memory/domain/MemoryMember.java new file mode 100644 index 000000000..c368b95b9 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/domain/MemoryMember.java @@ -0,0 +1,46 @@ +package com.staccato.memory.domain; + +import java.util.Objects; + +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 com.staccato.config.domain.BaseEntity; +import com.staccato.member.domain.Member; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemoryMember extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "memory_id", nullable = false) + private Memory memory; + + @Builder + public MemoryMember(@NonNull Member member, @NonNull Memory memory) { + this.member = member; + this.memory = memory; + memory.addMemoryMember(this); + } + + public boolean isMember(Member member) { + return Objects.equals(this.member, member); + } +} diff --git a/backend/src/main/java/com/staccato/memory/domain/Term.java b/backend/src/main/java/com/staccato/memory/domain/Term.java new file mode 100644 index 000000000..d42527b1d --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/domain/Term.java @@ -0,0 +1,59 @@ +package com.staccato.memory.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.chrono.ChronoLocalDate; +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import com.staccato.exception.StaccatoException; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Embeddable +public class Term { + @Column + private LocalDate startAt; + @Column + private LocalDate endAt; + + public Term(LocalDate startAt, LocalDate endAt) { + validateTermDates(startAt, endAt); + this.startAt = startAt; + this.endAt = endAt; + } + + private void validateTermDates(LocalDate startAt, LocalDate endAt) { + if (isOnlyOneDatePresent(startAt, endAt)) { + throw new StaccatoException("μΆ”μ–΅ μ‹œμž‘ λ‚ μ§œμ™€ 끝 λ‚ μ§œλ₯Ό λͺ¨λ‘ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + } + if (isInvalidTerm(startAt, endAt)) { + throw new StaccatoException("끝 λ‚ μ§œκ°€ μ‹œμž‘ λ‚ μ§œλ³΄λ‹€ μ•žμ„€ 수 μ—†μ–΄μš”."); + } + } + + private boolean isOnlyOneDatePresent(LocalDate startAt, LocalDate endAt) { + return (Objects.nonNull(startAt) && Objects.isNull(endAt)) || (Objects.isNull(startAt) && Objects.nonNull(endAt)); + } + + private boolean isInvalidTerm(LocalDate startAt, LocalDate endAt) { + return isExist(startAt, endAt) && endAt.isBefore(startAt); + } + + public boolean doesNotContain(LocalDateTime date) { + if(isExist(startAt, endAt)) { + ChronoLocalDate targetDate = ChronoLocalDate.from(date); + return (startAt.isAfter(targetDate) || endAt.isBefore(targetDate)); + } + return false; + } + + private boolean isExist(LocalDate startAt, LocalDate endAt) { + return Objects.nonNull(startAt) && Objects.nonNull(endAt); + } +} diff --git a/backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java b/backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java new file mode 100644 index 000000000..b21768370 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/repository/MemoryMemberRepository.java @@ -0,0 +1,22 @@ +package com.staccato.memory.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.staccato.memory.domain.MemoryMember; + +public interface MemoryMemberRepository extends JpaRepository { + List findAllByMemberIdOrderByMemoryCreatedAtDesc(long memberId); + + @Query(""" + SELECT mm FROM MemoryMember mm WHERE mm.member.id = :memberId + AND ((mm.memory.term.startAt is null AND mm.memory.term.endAt is null) + or (:date BETWEEN mm.memory.term.startAt AND mm.memory.term.endAt)) + ORDER BY mm.memory.createdAt DESC + """) + List findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(@Param("memberId") long memberId, @Param("date") LocalDate date); +} diff --git a/backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java b/backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java new file mode 100644 index 000000000..76e006028 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/repository/MemoryRepository.java @@ -0,0 +1,9 @@ +package com.staccato.memory.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.memory.domain.Memory; + +public interface MemoryRepository extends JpaRepository { + boolean existsByTitle(String title); +} diff --git a/backend/src/main/java/com/staccato/memory/service/MemoryService.java b/backend/src/main/java/com/staccato/memory/service/MemoryService.java new file mode 100644 index 000000000..6cc89e30c --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/MemoryService.java @@ -0,0 +1,119 @@ +package com.staccato.memory.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; +import com.staccato.memory.repository.MemoryMemberRepository; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; +import com.staccato.memory.service.dto.response.MomentResponse; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemoryService { + private final MemoryRepository memoryRepository; + private final MemoryMemberRepository memoryMemberRepository; + private final MomentRepository momentRepository; + + @Transactional + public MemoryIdResponse createMemory(MemoryRequest memoryRequest, Member member) { + validateMemoryTitle(memoryRequest.memoryTitle()); + Memory memory = memoryRequest.toMemory(); + memory.addMemoryMember(member); + memoryRepository.save(memory); + return new MemoryIdResponse(memory.getId()); + } + + public MemoryResponses readAllMemories(Member member) { + List memoryMembers = memoryMemberRepository.findAllByMemberIdOrderByMemoryCreatedAtDesc(member.getId()); + return MemoryResponses.from( + memoryMembers.stream() + .map(MemoryMember::getMemory) + .toList() + ); + } + + public MemoryNameResponses readAllMemoriesIncludingDate(Member member, LocalDate currentDate) { + List memoryMembers = memoryMemberRepository.findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(member.getId(), currentDate); + return MemoryNameResponses.from( + memoryMembers.stream() + .map(MemoryMember::getMemory) + .toList() + ); + } + + public MemoryDetailResponse readMemoryById(long memoryId, Member member) { + Memory memory = getMemoryById(memoryId); + validateOwner(memory, member); + List momentResponses = getMomentResponses(momentRepository.findAllByMemoryIdOrderByVisitedAt(memoryId)); + return new MemoryDetailResponse(memory, momentResponses); + } + + private List getMomentResponses(List moments) { + return moments.stream() + .map(moment -> new MomentResponse(moment, getMomentThumbnail(moment))) + .toList(); + } + + private String getMomentThumbnail(Moment moment) { + if (moment.hasImage()) { + return moment.getThumbnailUrl(); + } + return null; + } + + @Transactional + public void updateMemory(MemoryRequest memoryRequest, Long memoryId, Member member) { + Memory originMemory = getMemoryById(memoryId); + validateOwner(originMemory, member); + if (originMemory.isNotSameTitle(memoryRequest.memoryTitle())) { + validateMemoryTitle(memoryRequest.memoryTitle()); + } + Memory updatedMemory = memoryRequest.toMemory(); + List moments = momentRepository.findAllByMemoryIdOrderByVisitedAt(memoryId); + originMemory.update(updatedMemory, moments); + } + + private Memory getMemoryById(long memoryId) { + return memoryRepository.findById(memoryId) + .orElseThrow(() -> new StaccatoException("μš”μ²­ν•˜μ‹  좔얡을 찾을 수 μ—†μ–΄μš”.")); + } + + private void validateMemoryTitle(String title) { + if (memoryRepository.existsByTitle(title)) { + throw new StaccatoException("같은 이름을 가진 좔얡이 μžˆμ–΄μš”. λ‹€λ₯Έ μ΄λ¦„μœΌλ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + } + } + + @Transactional + public void deleteMemory(long memoryId, Member member) { + memoryRepository.findById(memoryId).ifPresent(memory -> { + validateOwner(memory, member); + momentRepository.deleteAllByMemoryId(memoryId); + memoryRepository.deleteById(memoryId); + }); + } + + private void validateOwner(Memory memory, Member member) { + if (memory.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java b/backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java new file mode 100644 index 000000000..7118a1539 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/request/MemoryRequest.java @@ -0,0 +1,47 @@ +package com.staccato.memory.service.dto.request; + +import java.time.LocalDate; +import java.util.Objects; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "좔얡을 생성/μˆ˜μ •ν•˜κΈ° μœ„ν•œ μš”μ²­ ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryRequest( + @Schema(example = "http://example.com/london.png") + String memoryThumbnailUrl, + @Schema(example = "런던 μΆ”μ–΅") + @NotBlank(message = "μΆ”μ–΅ 제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(max = 30, message = "제λͺ©μ€ 곡백 포함 30자 μ΄ν•˜λ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”.") + String memoryTitle, + @Schema(example = "런던 μ‹œλ‚΄ 탐방") + @Size(max = 500, message = "λ‚΄μš©μ˜ μ΅œλŒ€ ν—ˆμš© κΈ€μžμˆ˜λŠ” 곡백 포함 500μžμž…λ‹ˆλ‹€.") + String description, + @Schema(example = "2024-07-27") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate startAt, + @Schema(example = "2024-07-29") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate endAt) { + public MemoryRequest { + if (Objects.nonNull(memoryTitle)) { + memoryTitle = memoryTitle.trim(); + } + } + + public Memory toMemory() { + return Memory.builder() + .thumbnailUrl(memoryThumbnailUrl) + .title(memoryTitle) + .description(description) + .startAt(startAt) + .endAt(endAt) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java new file mode 100644 index 000000000..737fc6e5f --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryDetailResponse.java @@ -0,0 +1,49 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.member.service.dto.response.MemberResponse; +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "좔얡에 λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryDetailResponse( + @Schema(example = "1") + Long memoryId, + @Schema(example = "https://example.com/memorys/geumohrm.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memoryThumbnailUrl, + @Schema(example = "런던 μΆ”μ–΅") + String memoryTitle, + @Schema(example = "런던 μ‹œλ‚΄ 탐방") + @JsonInclude(JsonInclude.Include.NON_NULL) + String description, + @Schema(example = "2024-07-27") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-29") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt, + List mates, + List moments +) { + public MemoryDetailResponse(Memory memory, List momentResponses) { + this( + memory.getId(), + memory.getThumbnailUrl(), + memory.getTitle(), + memory.getDescription(), + memory.getTerm().getStartAt(), + memory.getTerm().getEndAt(), + toMemberResponses(memory), + momentResponses + ); + } + + private static List toMemberResponses(Memory memory) { + return memory.getMates().stream().map(MemberResponse::new).toList(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java new file mode 100644 index 000000000..b1aa0a825 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryIdResponse.java @@ -0,0 +1,10 @@ +package com.staccato.memory.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "좔얡을 μƒμ„±ν–ˆμ„ λ•Œμ— λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryIdResponse( + @Schema(example = "1") + long memoryId +) { +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java new file mode 100644 index 000000000..27f155535 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponse.java @@ -0,0 +1,17 @@ +package com.staccato.memory.service.dto.response; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "νŠΉμ • λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜λŠ” μΆ”μ–΅ λͺ©λ‘ 쑰회 μ‹œ 각각의 좔얡에 λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryNameResponse( + @Schema(example = "1") + Long memoryId, + @Schema(example = "런던 μΆ”μ–΅") + String memoryTitle +) { + public MemoryNameResponse(Memory memory) { + this(memory.getId(), memory.getTitle()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java new file mode 100644 index 000000000..57f18c92f --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryNameResponses.java @@ -0,0 +1,18 @@ +package com.staccato.memory.service.dto.response; + +import java.util.List; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "νŠΉμ • λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜λŠ” μΆ”μ–΅ λͺ©λ‘ 쑰회 μ‹œ λ°˜ν™˜λ˜λŠ” 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryNameResponses( + List memories +) { + public static MemoryNameResponses from(List memories) { + return new MemoryNameResponses(memories.stream() + .map(MemoryNameResponse::new) + .toList()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java new file mode 100644 index 000000000..06dd0a3de --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponse.java @@ -0,0 +1,35 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μΆ”μ–΅ λͺ©λ‘ 쑰회 μ‹œ 각각의 좔얡에 λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryResponse( + @Schema(example = "1") + Long memoryId, + @Schema(example = "https://example.com/memorys/geumohrm.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String memoryThumbnailUrl, + @Schema(example = "런던 μΆ”μ–΅") + String memoryTitle, + @Schema(example = "2024-07-27") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-29") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt +) { + public MemoryResponse(Memory memory) { + this( + memory.getId(), + memory.getThumbnailUrl(), + memory.getTitle(), + memory.getTerm().getStartAt(), + memory.getTerm().getEndAt() + ); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java new file mode 100644 index 000000000..402c934f8 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MemoryResponses.java @@ -0,0 +1,18 @@ +package com.staccato.memory.service.dto.response; + +import java.util.List; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μΆ”μ–΅ λͺ©λ‘ 쑰회 μ‹œ λ°˜ν™˜ λ˜λŠ” 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MemoryResponses( + List memories +) { + public static MemoryResponses from(List memories) { + return new MemoryResponses(memories.stream() + .map(MemoryResponse::new) + .toList()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java new file mode 100644 index 000000000..95eed9370 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/MomentResponse.java @@ -0,0 +1,25 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μΆ”μ–΅ 쑰회 μ‹œ λ³΄μ—¬μ£ΌλŠ” μŠ€νƒ€μΉ΄ν† μ˜ 정보에 λŒ€ν•œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MomentResponse( + @Schema(example = "1") + Long momentId, + @Schema(example = "런던 아이") + String placeName, + @Schema(example = "https://example.com/memorys/london_eye.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String momentImageUrl, + @Schema(example = "2024-07-27T11:58:20") + LocalDateTime visitedAt +) { + public MomentResponse(Moment moment, String momentImageUrl) { + this(moment.getId(), moment.getPlaceName(), momentImageUrl, moment.getVisitedAt()); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/MomentController.java b/backend/src/main/java/com/staccato/moment/controller/MomentController.java new file mode 100644 index 000000000..c29828063 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/MomentController.java @@ -0,0 +1,91 @@ +package com.staccato.moment.controller; + +import java.net.URI; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.config.auth.LoginMember; +import com.staccato.member.domain.Member; +import com.staccato.moment.controller.docs.MomentControllerDocs; +import com.staccato.moment.service.MomentService; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/moments") +@RequiredArgsConstructor +@Validated +public class MomentController implements MomentControllerDocs { + private final MomentService momentService; + + @PostMapping + public ResponseEntity createMoment( + @LoginMember Member member, + @Valid @RequestBody MomentRequest momentRequest + ) { + MomentIdResponse momentIdResponse = momentService.createMoment(momentRequest, member); + return ResponseEntity.created(URI.create("/moments/" + momentIdResponse.momentId())) + .body(momentIdResponse); + } + + @GetMapping + public ResponseEntity readAllMoment(@LoginMember Member member) { + MomentLocationResponses momentLocationResponses = momentService.readAllMoment(member); + return ResponseEntity.ok().body(momentLocationResponses); + } + + @GetMapping("/{momentId}") + public ResponseEntity readMomentById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId) { + MomentDetailResponse momentDetailResponse = momentService.readMomentById(momentId, member); + return ResponseEntity.ok().body(momentDetailResponse); + } + + @PutMapping(path = "/{momentId}") + public ResponseEntity updateMomentById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId, + @Valid @RequestBody MomentUpdateRequest request + ) { + momentService.updateMomentById(momentId, request, member); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{momentId}") + public ResponseEntity deleteMomentById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId + ) { + momentService.deleteMomentById(momentId, member); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{momentId}/feeling") + public ResponseEntity updateMomentFeelingById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId, + @Valid @RequestBody FeelingRequest feelingRequest + ) { + momentService.updateMomentFeelingById(momentId, member, feelingRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java b/backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java new file mode 100644 index 000000000..380aad8f5 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/docs/MomentControllerDocs.java @@ -0,0 +1,117 @@ +package com.staccato.moment.controller.docs; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +import com.staccato.member.domain.Member; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Moment", description = "Moment API") +public interface MomentControllerDocs { + @Operation(summary = "μŠ€νƒ€μΉ΄ν†  생성", description = "μŠ€νƒ€μΉ΄ν† λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  생성 성곡", responseCode = "201"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) ν•„μˆ˜ κ°’(사진을 μ œμ™Έν•œ λͺ¨λ“  κ°’)이 λˆ„λ½λ˜μ—ˆμ„ λ•Œ + + (2) μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” memoryId일 λ•Œ + + (3) μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ λ‚ μ§œ ν˜•μ‹μΌ λ•Œ + + (4) 사진이 5μž₯을 μ΄ˆκ³Όν–ˆμ„ λ•Œ + + (5) μŠ€νƒ€μΉ΄ν†  λ‚ μ§œκ°€ μΆ”μ–΅ 기간에 ν¬ν•¨λ˜μ§€ μ•Šμ„ λ•Œ + """, + responseCode = "400") + }) + ResponseEntity createMoment( + @Parameter(hidden = true) Member member, + @Parameter(required = true) @Valid MomentRequest momentRequest + ); + + @Operation(summary = "μŠ€νƒ€μΉ΄ν†  λͺ©λ‘ 쑰회", description = "μŠ€νƒ€μΉ΄ν†  λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  λͺ©λ‘ 쑰회 성곡", responseCode = "200") + ResponseEntity readAllMoment(@Parameter(hidden = true) Member member); + + @Operation(summary = "μŠ€νƒ€μΉ΄ν†  쑰회", description = "μŠ€νƒ€μΉ΄ν† λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  쑰회 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) μ‘°νšŒν•˜λ €λŠ” μŠ€νƒ€μΉ΄ν† κ°€ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ λ•Œ + + (2) Path Variable ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ + """, + responseCode = "400") + }) + ResponseEntity readMomentById( + @Parameter(hidden = true) Member member, + @Parameter(description = "μŠ€νƒ€μΉ΄ν†  ID", example = "1") @PathVariable @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId); + + @Operation(summary = "μŠ€νƒ€μΉ΄ν†  μˆ˜μ •", description = "μŠ€νƒ€μΉ΄ν† λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  μˆ˜μ • 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) μ‘°νšŒν•˜λ €λŠ” μŠ€νƒ€μΉ΄ν† κ°€ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ λ•Œ + + (2) Path Variable ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (3) μ‚¬μ§„μ˜ 총 κ°―μˆ˜κ°€ 5μž₯을 μ΄ˆκ³Όν•˜μ˜€μ„ λ•Œ + """, + responseCode = "400") + }) + ResponseEntity updateMomentById( + @Parameter(hidden = true) Member member, + @Parameter(description = "μŠ€νƒ€μΉ΄ν†  ID", example = "1") @PathVariable @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId, + @Parameter(required = true) @Valid MomentUpdateRequest request); + + @Operation(summary = "μŠ€νƒ€μΉ΄ν†  μ‚­μ œ", description = "μŠ€νƒ€μΉ΄ν† λ₯Ό μ‚­μ œν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  μ‚­μ œμ— μ„±κ³΅ν–ˆκ±°λ‚˜ ν•΄λ‹Ή μŠ€νƒ€μΉ΄ν† κ°€ μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 경우", responseCode = "200"), + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžμ— μ–‘μˆ˜κ°€ μ•„λ‹Œ 값을 κΈ°μž…ν–ˆμ„ 경우", responseCode = "400") + }) + ResponseEntity deleteMomentById( + @Parameter(hidden = true) Member member, + @Parameter(description = "μŠ€νƒ€μΉ΄ν†  ID", example = "1") @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId + ); + + @Operation(summary = "μŠ€νƒ€μΉ΄ν†  κΈ°λΆ„ 선택", description = "μŠ€νƒ€μΉ΄ν† μ˜ 기뢄을 μ„ νƒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(description = "μŠ€νƒ€μΉ΄ν†  κΈ°λΆ„ 선택 성곡", responseCode = "200"), + @ApiResponse(description = """ + <λ°œμƒ κ°€λŠ₯ν•œ μΌ€μ΄μŠ€> + + (1) μ‘°νšŒν•˜λ €λŠ” μŠ€νƒ€μΉ΄ν† κ°€ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ λ•Œ + + (2) Path Variable ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (3) RequestBody ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ λ•Œ + + (4) μš”μ²­ν•œ κΈ°λΆ„ ν‘œν˜„μ„ 찾을 수 없을 λ•Œ + """, + responseCode = "400") + }) + ResponseEntity updateMomentFeelingById( + @Parameter(hidden = true) Member member, + @Parameter(description = "μŠ€νƒ€μΉ΄ν†  ID", example = "1") @Min(value = 1L, message = "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") long momentId, + @Parameter(required = true) @Valid FeelingRequest feelingRequest); +} diff --git a/backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java b/backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 000000000..aad02c6ff --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/docs/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,31 @@ +package com.staccato.moment.controller.docs; + +import java.lang.reflect.Type; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/Feeling.java b/backend/src/main/java/com/staccato/moment/domain/Feeling.java new file mode 100644 index 000000000..fbec64c3c --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/Feeling.java @@ -0,0 +1,31 @@ +package com.staccato.moment.domain; + +import java.util.Arrays; + +import com.staccato.exception.StaccatoException; + +public enum Feeling { + HAPPY("happy"), + ANGRY("angry"), + SAD("sad"), + SCARED("scared"), + EXCITED("excited"), + NOTHING("nothing"); + + private final String feeling; + + Feeling(String feeling) { + this.feeling = feeling; + } + + public static Feeling match(String value) { + return Arrays.stream(values()) + .filter(mood -> mood.getValue().equals(value)) + .findFirst() + .orElseThrow(() -> new StaccatoException("μš”μ²­ν•˜μ‹  κΈ°λΆ„ ν‘œν˜„μ„ 찾을 수 μ—†μ–΄μš”.")); + } + + public String getValue() { + return feeling; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/Moment.java b/backend/src/main/java/com/staccato/moment/domain/Moment.java new file mode 100644 index 000000000..4f3adef79 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/Moment.java @@ -0,0 +1,103 @@ +package com.staccato.moment.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; + +import com.staccato.comment.domain.Comment; +import com.staccato.config.domain.BaseEntity; +import com.staccato.exception.StaccatoException; +import com.staccato.memory.domain.Memory; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Moment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private LocalDateTime visitedAt; + @Column(nullable = false) + private String placeName; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Feeling feeling = Feeling.NOTHING; + @Column(nullable = false) + @Embedded + private Spot spot; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "memory_id", nullable = false) + private Memory memory; + @Embedded + private MomentImages momentImages = new MomentImages(); + @OneToMany(mappedBy = "moment", orphanRemoval = true, cascade = CascadeType.REMOVE) + private List comments = new ArrayList<>(); + + @Builder + public Moment( + @NonNull LocalDateTime visitedAt, + @NonNull String placeName, + @NonNull String address, + @NonNull BigDecimal latitude, + @NonNull BigDecimal longitude, + @NonNull MomentImages momentImages, + @NonNull Memory memory + ) { + validateIsWithinMemoryDuration(visitedAt, memory); + this.visitedAt = visitedAt.truncatedTo(ChronoUnit.SECONDS); + this.placeName = placeName.trim(); + this.spot = new Spot(address, latitude, longitude); + this.momentImages.addAll(momentImages, this); + this.memory = memory; + } + + private void validateIsWithinMemoryDuration(LocalDateTime visitedAt, Memory memory) { + if (memory.isWithoutDuration(visitedAt)) { + throw new StaccatoException("좔얡에 ν¬ν•¨λ˜μ§€ μ•ŠλŠ” λ‚ μ§œμž…λ‹ˆλ‹€."); + } + } + + public void addComment(Comment comment) { + this.comments.add(comment); + } + + public void update(String placeName, MomentImages newMomentImages) { + this.placeName = placeName; + this.momentImages.update(newMomentImages, this); + } + + public String getThumbnailUrl() { + return momentImages.getImages().get(0).getImageUrl(); + } + + public boolean hasImage() { + return momentImages.isNotEmpty(); + } + + public void changeFeeling(Feeling feeling) { + this.feeling = feeling; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/MomentImage.java b/backend/src/main/java/com/staccato/moment/domain/MomentImage.java new file mode 100644 index 000000000..35486b35e --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/MomentImage.java @@ -0,0 +1,39 @@ +package com.staccato.moment.domain; + +import jakarta.annotation.Nonnull; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MomentImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(columnDefinition = "TEXT") + private String imageUrl; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moment_id", nullable = false) + private Moment moment; + + @Builder + public MomentImage(@Nonnull String imageUrl) { + this.imageUrl = imageUrl; + } + + protected void belongTo(Moment moment) { + this.moment = moment; + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/MomentImages.java b/backend/src/main/java/com/staccato/moment/domain/MomentImages.java new file mode 100644 index 000000000..9d4b32802 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/MomentImages.java @@ -0,0 +1,56 @@ +package com.staccato.moment.domain; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; + +import com.staccato.exception.StaccatoException; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MomentImages { + private static final int MAX_COUNT = 5; + @OneToMany(mappedBy = "moment", orphanRemoval = true, cascade = CascadeType.ALL) + private List images = new ArrayList<>(); + + public MomentImages(List addedImages) { + validateNumberOfImages(addedImages); + this.images.addAll(addedImages.stream() + .map(MomentImage::new) + .toList()); + } + + private void validateNumberOfImages(List addedImages) { + if (addedImages.size() > MAX_COUNT) { + throw new StaccatoException("사진은 5μž₯을 μ΄ˆκ³Όν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + protected void addAll(MomentImages newMomentImages, Moment moment) { + newMomentImages.images.forEach(image -> { + this.images.add(image); + image.belongTo(moment); + }); + } + + protected void update(MomentImages momentImages, Moment moment) { + removeExistsImages(new ArrayList<>(images)); + addAll(momentImages, moment); + } + + private void removeExistsImages(List originalImages) { + originalImages.forEach(this.images::remove); + } + + public boolean isNotEmpty() { + return !images.isEmpty(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/domain/Spot.java b/backend/src/main/java/com/staccato/moment/domain/Spot.java new file mode 100644 index 000000000..40bee500a --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/domain/Spot.java @@ -0,0 +1,23 @@ +package com.staccato.moment.domain; + +import java.math.BigDecimal; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Embeddable +public class Spot { + @Column(nullable = false) + private String address; + @Column(nullable = false, columnDefinition = "DECIMAL(16, 14)") + private BigDecimal latitude; + @Column(nullable = false, columnDefinition = "DECIMAL(17, 14)") + private BigDecimal longitude; +} diff --git a/backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java b/backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java new file mode 100644 index 000000000..3a3a7b879 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/repository/MomentImageRepository.java @@ -0,0 +1,11 @@ +package com.staccato.moment.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.moment.domain.MomentImage; + +public interface MomentImageRepository extends JpaRepository { + Optional findFirstByMomentId(long momentId); +} diff --git a/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java b/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java new file mode 100644 index 000000000..4e23d4101 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java @@ -0,0 +1,16 @@ +package com.staccato.moment.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +public interface MomentRepository extends JpaRepository { + List findAllByMemoryIdOrderByVisitedAt(long memoryId); + + void deleteAllByMemoryId(long memoryId); + + List findAllByMemory_MemoryMembers_Member(Member member); +} diff --git a/backend/src/main/java/com/staccato/moment/service/MomentService.java b/backend/src/main/java/com/staccato/moment/service/MomentService.java new file mode 100644 index 000000000..56332b4a8 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/MomentService.java @@ -0,0 +1,96 @@ +package com.staccato.moment.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Feeling; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MomentService { + private final MomentRepository momentRepository; + private final MemoryRepository memoryRepository; + + @Transactional + public MomentIdResponse createMoment(MomentRequest momentRequest, Member member) { + Memory memory = getMemoryById(momentRequest.memoryId()); + validateOwner(memory, member); + Moment moment = momentRequest.toMoment(memory); + + momentRepository.save(moment); + + return new MomentIdResponse(moment.getId()); + } + + private Memory getMemoryById(long memoryId) { + return memoryRepository.findById(memoryId) + .orElseThrow(() -> new StaccatoException("μš”μ²­ν•˜μ‹  좔얡을 찾을 수 μ—†μ–΄μš”.")); + } + + public MomentLocationResponses readAllMoment(Member member) { + return new MomentLocationResponses(momentRepository.findAllByMemory_MemoryMembers_Member(member) + .stream() + .map(MomentLocationResponse::new).toList()); + } + + public MomentDetailResponse readMomentById(long momentId, Member member) { + Moment moment = getMomentById(momentId); + validateOwner(moment.getMemory(), member); + return new MomentDetailResponse(moment); + } + + @Transactional + public void updateMomentById( + long momentId, + MomentUpdateRequest momentUpdateRequest, + Member member + ) { + Moment moment = getMomentById(momentId); + validateOwner(moment.getMemory(), member); + moment.update(momentUpdateRequest.placeName(), momentUpdateRequest.toMomentImages()); + } + + private Moment getMomentById(long momentId) { + return momentRepository.findById(momentId) + .orElseThrow(() -> new StaccatoException("μš”μ²­ν•˜μ‹  μŠ€νƒ€μΉ΄ν† λ₯Ό 찾을 수 μ—†μ–΄μš”.")); + } + + @Transactional + public void deleteMomentById(long momentId, Member member) { + momentRepository.findById(momentId).ifPresent(moment -> { + validateOwner(moment.getMemory(), member); + momentRepository.deleteById(momentId); + }); + } + + private void validateOwner(Memory memory, Member member) { + if (memory.isNotOwnedBy(member)) { + throw new ForbiddenException(); + } + } + + @Transactional + public void updateMomentFeelingById(long momentId, Member member, FeelingRequest feelingRequest) { + Moment moment = getMomentById(momentId); + validateOwner(moment.getMemory(), member); + Feeling feeling = feelingRequest.toFeeling(); + moment.changeFeeling(feeling); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java new file mode 100644 index 000000000..325d2f945 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/FeelingRequest.java @@ -0,0 +1,14 @@ +package com.staccato.moment.service.dto.request; + +import jakarta.validation.constraints.NotNull; + +import com.staccato.moment.domain.Feeling; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν†  κΈ°λΆ„ ν‘œν˜„ μš”μ²­") +public record FeelingRequest(@Schema(description = "κΈ°λΆ„ ν‘œν˜„", example = "happy") @NotNull(message = "κΈ°λΆ„ 값을 μž…λ ₯ν•΄μ£Όμ„Έμš”.") String feeling) { + public Feeling toFeeling() { + return Feeling.match(feeling); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java new file mode 100644 index 000000000..012e4b227 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentRequest.java @@ -0,0 +1,67 @@ +package com.staccato.moment.service.dto.request; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν†  생성 μ‹œ μš”μ²­ ν˜•μ‹μž…λ‹ˆλ‹€. 단, λ©€ν‹°νŒŒνŠΈλ‘œ λ³΄λ‚΄λŠ” 사진 νŒŒμΌμ€ 여기에 ν¬ν•¨λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") +public record MomentRequest( + @Schema(example = "런던 λ°•λ¬Όκ΄€") + @NotBlank(message = "μŠ€νƒ€μΉ΄ν†  제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(max = 30, message = "μŠ€νƒ€μΉ΄ν†  제λͺ©μ€ 곡백 포함 30자 μ΄ν•˜λ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”.") + String placeName, + @Schema(example = "Great Russell St, London WC1B 3DG") + @NotNull(message = "μŠ€νƒ€μΉ΄ν† μ˜ μ£Όμ†Œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String address, + @Schema(example = "51.51978412729915") + @NotNull(message = "μŠ€νƒ€μΉ΄ν† μ˜ μœ„λ„λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + @NotNull(message = "μŠ€νƒ€μΉ΄ν† μ˜ 경도λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + BigDecimal longitude, + @Schema(example = "2024-07-27") + @NotNull(message = "μŠ€νƒ€μΉ΄ν†  λ‚ μ§œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime visitedAt, + @Schema(example = "1") + @NotNull(message = "좔얡을 μ„ νƒν•΄μ£Όμ„Έμš”.") + @Min(value = 1L, message = "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€.") + long memoryId, + @ArraySchema( + arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + @Size(max = 5, message = "사진은 5μž₯κΉŒμ§€λ§Œ μΆ”κ°€ν•  수 μžˆμ–΄μš”.") + List momentImageUrls +) { + public MomentRequest { + if (Objects.nonNull(placeName)) { + placeName = placeName.trim(); + } + } + + public Moment toMoment(Memory memory) { + return Moment.builder() + .visitedAt(visitedAt) + .placeName(placeName) + .latitude(latitude) + .longitude(longitude) + .address(address) + .memory(memory) + .momentImages(new MomentImages(momentImageUrls)) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java new file mode 100644 index 000000000..336855a75 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/MomentUpdateRequest.java @@ -0,0 +1,26 @@ +package com.staccato.moment.service.dto.request; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import com.staccato.moment.domain.MomentImages; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν†  μˆ˜μ • μ‹œ μš”μ²­ ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MomentUpdateRequest( + @Schema(example = "남산 μ„œμšΈνƒ€μ›Œ") + @NotNull(message = "μŠ€νƒ€μΉ΄ν†  제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String placeName, + @ArraySchema( + arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + @Size(max = 5, message = "사진은 5μž₯κΉŒμ§€λ§Œ μΆ”κ°€ν•  수 μžˆμ–΄μš”.") + List momentImageUrls) { + + public MomentImages toMomentImages() { + return new MomentImages(momentImageUrls); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java new file mode 100644 index 000000000..82ff55878 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentDetailResponse.java @@ -0,0 +1,46 @@ +package com.staccato.moment.service.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.staccato.comment.service.dto.response.CommentResponse; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImage; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν† λ₯Ό μ‘°νšŒν–ˆμ„ λ•Œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MomentDetailResponse( + @Schema(example = "1") + long momentId, + @Schema(example = "1") + long memoryId, + @Schema(example = "2024 μ„œμšΈ νˆ¬μ–΄") + String memoryTitle, + @Schema(example = "남산 μ„œμšΈνƒ€μ›Œ") + String placeName, + @ArraySchema(arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + List momentImageUrls, + @Schema(example = "2021-11-08T11:58:20") + LocalDateTime visitedAt, + @Schema(example = "happy") + String feeling, + @Schema(example = "μ„œμšΈ μš©μ‚°κ΅¬ 남산곡원길 105") + String address, + List comments +) { + public MomentDetailResponse(Moment moment) { + this( + moment.getId(), + moment.getMemory().getId(), + moment.getMemory().getTitle(), + moment.getPlaceName(), + moment.getMomentImages().getImages().stream().map(MomentImage::getImageUrl).toList(), + moment.getVisitedAt(), + moment.getFeeling().getValue(), + moment.getSpot().getAddress(), + moment.getComments().stream().map(CommentResponse::new).toList() + ); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java new file mode 100644 index 000000000..e6988b164 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentIdResponse.java @@ -0,0 +1,10 @@ +package com.staccato.moment.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν†  생성 μ‹œ 응닡 ν˜•μ‹μž…λ‹ˆλ‹€.") +public record MomentIdResponse( + @Schema(example = "1") + long momentId +) { +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java new file mode 100644 index 000000000..7a16de0eb --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponse.java @@ -0,0 +1,21 @@ +package com.staccato.moment.service.dto.response; + +import java.math.BigDecimal; + +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν†  λͺ©λ‘ 쀑 ν•˜λ‚˜μ˜ μŠ€νƒ€μΉ΄ν† μ— ν•΄λ‹Ήν•˜λŠ” μ‘λ‹΅μž…λ‹ˆλ‹€.") +public record MomentLocationResponse( + @Schema(example = "1") + long momentId, + @Schema(example = "51.51978412729915") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + BigDecimal longitude) { + + public MomentLocationResponse(Moment moment) { + this(moment.getId(), moment.getSpot().getLatitude(), moment.getSpot().getLongitude()); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java new file mode 100644 index 000000000..96ac2986e --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/MomentLocationResponses.java @@ -0,0 +1,9 @@ +package com.staccato.moment.service.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "μŠ€νƒ€μΉ΄ν†  λͺ©λ‘μ— ν•΄λ‹Ήν•˜λŠ” μ‘λ‹΅μž…λ‹ˆλ‹€.") +public record MomentLocationResponses(List momentLocationResponses) { +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 000000000..e350c0309 --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,52 @@ +spring: + config: + activate: + on-profile: dev + application: + name: staccato + sql: + init: + mode: always + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + database: MYSQL + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: techcourse-project-2024 + endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + cloudfront: + endpoint: https://d25aribbn0gp8k.cloudfront.net + region: + static: ap-northeast-2 + stack: + auto: false +image: + folder: + name: image/ diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 000000000..d517a93c3 --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,50 @@ +spring: + config: + activate: + on-profile: local + application: + name: staccato + sql: + init: + mode: always + h2: + console: + enabled: true + datasource: + url: jdbc:h2:mem:staccato + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: techcourse-project-2024 + endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + cloudfront: + endpoint: https://d25aribbn0gp8k.cloudfront.net + region: + static: ap-northeast-2 + stack: + auto: false +image: + folder: + name: image/ diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 000000000..c17137e0d --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,54 @@ +spring: + config: + activate: + on-profile: prod + application: + name: staccato + sql: + init: + mode: always + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + database: MYSQL + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: validate + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + api-docs: + enabled: false +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: ${AWS_S3_BUCKET} + endpoint: ${AWS_S3_ENDPOINT} + cloudfront: + endpoint: ${AWS_CLOUDFRONT_ENDPOINT} + region: + static: ${AWS_REGION_STATIC} + stack: + auto: false +image: + folder: + name: image-prod/ diff --git a/backend/src/main/resources/application-stage.yml b/backend/src/main/resources/application-stage.yml new file mode 100644 index 000000000..aece96fb9 --- /dev/null +++ b/backend/src/main/resources/application-stage.yml @@ -0,0 +1,52 @@ +spring: + config: + activate: + on-profile: stage + application: + name: staccato + sql: + init: + mode: always + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + database: MYSQL + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 +security: + jwt: + token: + secret-key: ${SECRET_KEY} + admin: + key: ${ADMIN_KEY} + token: ${ADMIN_TOKEN} +cloud: + aws: + s3: + bucket: techcourse-project-2024 + endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + cloudfront: + endpoint: https://d25aribbn0gp8k.cloudfront.net + region: + static: ap-northeast-2 + stack: + auto: false +image: + folder: + name: image/ diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 000000000..543fa4da7 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,4 @@ +spring: + profiles: + active: local + diff --git a/backend/src/main/resources/console-appender.xml b/backend/src/main/resources/console-appender.xml new file mode 100644 index 000000000..46b452f4c --- /dev/null +++ b/backend/src/main/resources/console-appender.xml @@ -0,0 +1,7 @@ + + + + [%d{yyyy-MM-dd HH:mm:ss}:%-4relative] [%thread] [request_id=%X{request_id:-startup}] %highlight(%-5level) [%C.%M.-%L] - %msg%n + + + diff --git a/backend/src/main/resources/error-appender.xml b/backend/src/main/resources/error-appender.xml new file mode 100644 index 000000000..ff8f75fac --- /dev/null +++ b/backend/src/main/resources/error-appender.xml @@ -0,0 +1,20 @@ + + + ./logs/error/error-${BY_DATE}.log + + + ERROR + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level [%C.%M.-%L] - %msg%n + + + ./backup/error/error-%d{yyyy-MM-dd}.%i.log + 10MB + 15 + 3GB + + + diff --git a/backend/src/main/resources/info-appender.xml b/backend/src/main/resources/info-appender.xml new file mode 100644 index 000000000..da27ad381 --- /dev/null +++ b/backend/src/main/resources/info-appender.xml @@ -0,0 +1,20 @@ + + + ./logs/info/info-${BY_DATE}.log + + + INFO + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level - %msg%n + + + ./backup/info/info-%d{yyyy-MM-dd}.%i.log + 10MB + 15 + 3GB + + + diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..99a0b5a22 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/warn-appender.xml b/backend/src/main/resources/warn-appender.xml new file mode 100644 index 000000000..7abeb8b76 --- /dev/null +++ b/backend/src/main/resources/warn-appender.xml @@ -0,0 +1,20 @@ + + + ./logs/warn/warn-${BY_DATE}.log + + + WARN + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level [%C.%M.-%L] - %msg%n + + + ./backup/warn/warn-%d{yyyy-MM-dd}.%i.log + 10MB + 15 + 3GB + + + diff --git a/backend/src/test/java/com/staccato/IntegrationTest.java b/backend/src/test/java/com/staccato/IntegrationTest.java new file mode 100644 index 000000000..8e62b1d0b --- /dev/null +++ b/backend/src/test/java/com/staccato/IntegrationTest.java @@ -0,0 +1,22 @@ +package com.staccato; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import com.staccato.util.DatabaseCleanerExtension; + +import io.restassured.RestAssured; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class IntegrationTest { + @LocalServerPort + private int port; + + @BeforeEach + void setPort() { + RestAssured.port = port; + } +} diff --git a/backend/src/test/java/com/staccato/ServiceSliceTest.java b/backend/src/test/java/com/staccato/ServiceSliceTest.java new file mode 100644 index 000000000..89fe32199 --- /dev/null +++ b/backend/src/test/java/com/staccato/ServiceSliceTest.java @@ -0,0 +1,13 @@ +package com.staccato; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import com.staccato.util.DatabaseCleanerExtension; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@Import({TestConfig.class}) +public abstract class ServiceSliceTest { +} diff --git a/backend/src/test/java/com/staccato/StaccatoApplicationTests.java b/backend/src/test/java/com/staccato/StaccatoApplicationTests.java new file mode 100644 index 000000000..861820789 --- /dev/null +++ b/backend/src/test/java/com/staccato/StaccatoApplicationTests.java @@ -0,0 +1,13 @@ +package com.staccato; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class StaccatoApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/src/test/java/com/staccato/TestConfig.java b/backend/src/test/java/com/staccato/TestConfig.java new file mode 100644 index 000000000..040f083de --- /dev/null +++ b/backend/src/test/java/com/staccato/TestConfig.java @@ -0,0 +1,15 @@ +package com.staccato; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import com.staccato.image.infrastructure.FakeS3ObjectClient; +import com.staccato.image.infrastructure.S3ObjectClient; + +@TestConfiguration +public class TestConfig { + @Bean + public S3ObjectClient cloudStorageClient() { + return new FakeS3ObjectClient(); + } +} diff --git a/backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java new file mode 100644 index 000000000..075ba0d79 --- /dev/null +++ b/backend/src/test/java/com/staccato/auth/controller/AuthControllerTest.java @@ -0,0 +1,77 @@ +package com.staccato.auth.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; +import com.staccato.exception.ExceptionResponse; + +@WebMvcTest(AuthController.class) +class AuthControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private AuthService authService; + + @DisplayName("μœ νš¨ν•œ 둜그인 μš”μ²­μ΄ λ“€μ–΄μ˜€λ©΄ 성곡 응닡을 ν•œλ‹€.") + @Test + void login() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest("staccato"); + LoginResponse loginResponse = new LoginResponse("staccatotoken"); + when(authService.login(loginRequest)).thenReturn(loginResponse); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(loginResponse))); + } + + @DisplayName("λ‹‰λ„€μž„μ„ μž…λ ₯ν•˜μ§€ μ•ŠμœΌλ©΄ 400을 λ°˜ν™˜ν•œλ‹€.") + @Test + void cannotLoginIfBadRequest() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest(null); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "λ‹‰λ„€μž„μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("20자λ₯Ό μ΄ˆκ³Όν•˜λ©΄ 400을 λ°˜ν™˜ν•œλ‹€.") + @Test + void cannotLoginIfLengthExceeded() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest("κ°€".repeat(21)); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "1자 이상 20자 μ΄ν•˜μ˜ λ‹‰λ„€μž„μœΌλ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + + // when & then + mockMvc.perform(post("/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java b/backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java new file mode 100644 index 000000000..2f69cc24e --- /dev/null +++ b/backend/src/test/java/com/staccato/auth/service/AuthServiceTest.java @@ -0,0 +1,68 @@ +package com.staccato.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.auth.service.dto.request.LoginRequest; +import com.staccato.auth.service.dto.response.LoginResponse; +import com.staccato.exception.StaccatoException; +import com.staccato.exception.UnauthorizedException; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; + +class AuthServiceTest extends ServiceSliceTest { + @Autowired + private AuthService authService; + @Autowired + private MemberRepository memberRepository; + + @DisplayName("μž…λ ₯받은 λ‹‰λ„€μž„μœΌλ‘œ 멀버λ₯Ό μ €μž₯ν•˜κ³ , 토큰을 μƒμ„±ν•œλ‹€.") + @Test + void login() { + // given + String nickname = "staccato"; + LoginRequest loginRequest = new LoginRequest(nickname); + + // when + LoginResponse loginResponse = authService.login(loginRequest); + + // then + assertAll( + () -> assertThat(memberRepository.findAll()).hasSize(1), + () -> assertThat(loginResponse.token()).isNotNull() + ); + } + + @DisplayName("μž…λ ₯받은 λ‹‰λ„€μž„μ΄ 이미 μ‘΄μž¬ν•˜λŠ” λ‹‰λ„€μž„μΈ 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotLoginByDuplicated() { + // given + String nickname = "staccato"; + memberRepository.save(Member.builder().nickname(nickname).build()); + LoginRequest loginRequest = new LoginRequest(nickname); + + // when & then + assertThatThrownBy(() -> authService.login(loginRequest)) + .isInstanceOf(StaccatoException.class) + .hasMessage("이미 μ‘΄μž¬ν•˜λŠ” λ‹‰λ„€μž„μž…λ‹ˆλ‹€. λ‹€μ‹œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + } + + @DisplayName("λ§Œμ•½ 전달 받은 토큰이 null일 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotExtractMemberByUnknown() { + // given + String nickname = "staccato"; + memberRepository.save(Member.builder().nickname(nickname).build()); + + // when & then + assertThatThrownBy(() -> authService.extractFromToken(null)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("μΈμ¦λ˜μ§€ μ•Šμ€ μ‚¬μš©μžμž…λ‹ˆλ‹€."); + } +} diff --git a/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java new file mode 100644 index 000000000..a2b44caf8 --- /dev/null +++ b/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java @@ -0,0 +1,249 @@ +package com.staccato.comment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.comment.service.CommentService; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponse; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; + +@WebMvcTest(CommentController.class) +public class CommentControllerTest { + private static final int MAX_CONTENT_LENGTH = 500; + private static final int MIN_CONTENT_LENGTH = 1; + private static final long MIN_MOMENT_ID = 1L; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private CommentService commentService; + @MockBean + private AuthService authService; + + static Stream commentRequestProvider() { + return Stream.of( + new CommentRequest(MIN_MOMENT_ID, "1".repeat(MIN_CONTENT_LENGTH)), + new CommentRequest(MIN_MOMENT_ID, "1".repeat(MAX_CONTENT_LENGTH)) + ); + } + + static Stream invalidCommentRequestProvider() { + return Stream.of( + Arguments.of( + new CommentRequest(null, "μ˜ˆμ‹œ λŒ“κΈ€ λ‚΄μš©"), + "μŠ€νƒ€μΉ΄ν† λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID - 1, "μ˜ˆμ‹œ λŒ“κΈ€ λ‚΄μš©"), + "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, null), + "λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, ""), + "λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, " "), + "λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new CommentRequest(MIN_MOMENT_ID, "1".repeat(MAX_CONTENT_LENGTH + 1)), + "λŒ“κΈ€μ€ 곡백 포함 500자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ) + ); + } + + @DisplayName("μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ λŒ“κΈ€μ„ μƒμ„±ν•˜λ©΄ μ„±κ³΅ν•œλ‹€.") + @ParameterizedTest + @MethodSource("commentRequestProvider") + void createComment(CommentRequest commentRequest) throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + when(commentService.createComment(any(), any())).thenReturn(1L); + + // when & then + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(commentRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/comments/1")); + } + + @DisplayName("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ ν˜•μ‹μœΌλ‘œ 정보λ₯Ό μž…λ ₯ν•˜λ©΄, λŒ“κΈ€μ„ 생성할 수 μ—†λ‹€.") + @ParameterizedTest + @MethodSource("invalidCommentRequestProvider") + void createCommentFail(CommentRequest commentRequest, String expectedMessage) throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + when(commentService.createComment(any(), any())).thenReturn(1L); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(commentRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ λŒ“κΈ€ 읽기λ₯Ό μ‹œλ„ν•˜λ©΄ μ„±κ³΅ν•œλ‹€.") + @Test + void readCommentsByMomentId() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentResponses commentResponses = new CommentResponses(List.of( + new CommentResponse(1L, 1L, "member", "image.jpg", "λ‚΄μš©") + )); + when(commentService.readAllCommentsByMomentId(any(), any())).thenReturn(commentResponses); + + // when & then + mockMvc.perform(get("/comments") + .param("momentId", "1") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(commentResponses))); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 경우 λŒ“κΈ€ 읽기에 μ‹€νŒ¨ν•œλ‹€.") + @Test + void readCommentsByMomentIdFail() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentResponses commentResponses = new CommentResponses(List.of( + new CommentResponse(1L, 1L, "member", "image.jpg", "λ‚΄μš©") + )); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + when(commentService.readAllCommentsByMomentId(any(), any())).thenReturn(commentResponses); + + // when & then + mockMvc.perform(get("/comments") + .param("momentId", "0") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ λŒ“κΈ€ μˆ˜μ •μ„ μ‹œλ„ν•˜λ©΄ μ„±κ³΅ν•œλ‹€.") + @Test + void updateComment() throws Exception { + // given + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest("updated content"); + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/comments") + .param("commentId", "1") + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("λŒ“κΈ€ μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 경우 λŒ“κΈ€ μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void updateCommentFail() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest("updated content"); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "λŒ“κΈ€ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + + // when & then + mockMvc.perform(put("/comments") + .param("commentId", "0") + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•˜μ§€ μ•Šμ„ 경우 λŒ“κΈ€ μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void updateCommentFailByBlank(String updatedContent) throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "λŒ“κΈ€ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + + // when & then + mockMvc.perform(put("/comments") + .param("commentId", "1") + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ λŒ“κΈ€ μ‚­μ œλ₯Ό μ‹œλ„ν•˜λ©΄ μ„±κ³΅ν•œλ‹€.") + @Test + void deleteComment() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/comments") + .param("commentId", "1") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("λŒ“κΈ€ μ‹λ³„μžκ°€ μ–‘μˆ˜κ°€ 아닐 경우 λŒ“κΈ€ μ‚­μ œμ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void deleteCommentFail() throws Exception { + // given + when(authService.extractFromToken(any())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "λŒ“κΈ€ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + + // when & then + mockMvc.perform(delete("/comments") + .param("commentId", "0") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java b/backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java new file mode 100644 index 000000000..5f0e6334c --- /dev/null +++ b/backend/src/test/java/com/staccato/comment/service/CommentServiceTest.java @@ -0,0 +1,209 @@ +package com.staccato.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.comment.domain.Comment; +import com.staccato.comment.repository.CommentRepository; +import com.staccato.comment.service.dto.request.CommentRequest; +import com.staccato.comment.service.dto.request.CommentUpdateRequest; +import com.staccato.comment.service.dto.response.CommentResponse; +import com.staccato.comment.service.dto.response.CommentResponses; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.CommentFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +class CommentServiceTest extends ServiceSliceTest { + @Autowired + private CommentService commentService; + @Autowired + private CommentRepository commentRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MomentRepository momentRepository; + + @DisplayName("μŠ€νƒ€μΉ΄ν† κ°€ μ‘΄μž¬ν•˜λ©΄ λŒ“κΈ€ 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void createComment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + CommentRequest commentRequest = new CommentRequest(moment.getId(), "content"); + + // when + long commentId = commentService.createComment(commentRequest, member); + + // then + assertThat(commentRepository.findById(commentId)).isNotEmpty(); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μŠ€νƒ€μΉ΄ν† μ— λŒ“κΈ€ 생성을 μ‹œλ„ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void createCommentFailByNotExistMoment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + CommentRequest commentRequest = new CommentRequest(1L, "content"); + + // when & then + assertThatThrownBy(() -> commentService.createComment(commentRequest, member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μŠ€νƒ€μΉ΄ν† λ₯Ό 찾을 수 μ—†μ–΄μš”."); + } + + @DisplayName("κΆŒν•œμ΄ μ—†λŠ” μŠ€νƒ€μΉ΄ν† μ— λŒ“κΈ€ 생성을 μ‹œλ„ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void createCommentFailByForbidden() { + // given + Member momentOwner = memberRepository.save(MemberFixture.create("momentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(momentOwner)); + momentRepository.save(MomentFixture.create(memory)); + CommentRequest commentRequest = new CommentRequest(1L, "content"); + + // when & then + assertThatThrownBy(() -> commentService.createComment(commentRequest, unexpectedMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("νŠΉμ • μŠ€νƒ€μΉ΄ν† μ— μ†ν•œ λͺ¨λ“  λŒ“κΈ€μ„ 생성 순으둜 μ‘°νšŒν•œλ‹€.") + @Test + void readAllByMomentId() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Moment anotherMoment = momentRepository.save(MomentFixture.create(memory)); + CommentRequest commentRequest1 = new CommentRequest(moment.getId(), "content"); + CommentRequest commentRequest2 = new CommentRequest(moment.getId(), "content"); + CommentRequest commentRequestOfAnotherMoment = new CommentRequest(anotherMoment.getId(), "content"); + long commentId1 = commentService.createComment(commentRequest1, member); + long commentId2 = commentService.createComment(commentRequest2, member); + commentService.createComment(commentRequestOfAnotherMoment, member); + + // when + CommentResponses commentResponses = commentService.readAllCommentsByMomentId(member, moment.getId()); + + // then + assertThat(commentResponses.comments().stream().map(CommentResponse::commentId).toList()) + .containsExactly(commentId1, commentId2); + } + + @DisplayName("쑰회 κΆŒν•œμ΄ μ—†λŠ” μŠ€νƒ€μΉ΄ν† μ— 달린 λŒ“κΈ€λ“€ 쑰회λ₯Ό μ‹œλ„ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void readAllByMomentIdFailByForbidden() { + // given + Member momentOwner = memberRepository.save(MemberFixture.create("momentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(momentOwner)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + commentRepository.save(CommentFixture.create(moment, momentOwner)); + + // when & then + assertThatThrownBy(() -> commentService.readAllCommentsByMomentId(unexpectedMember, moment.getId())) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("본인이 μ“΄ λŒ“κΈ€μ€ μˆ˜μ •ν•  수 μžˆλ‹€.") + @Test + void updateComment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, member)); + + String updatedContent = "updated content"; + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + + // when + commentService.updateComment(member, comment.getId(), commentUpdateRequest); + + // then + assertThat(commentRepository.findById(comment.getId()).get().getContent()).isEqualTo(updatedContent); + } + + @DisplayName("μˆ˜μ •ν•˜λ €λŠ” λŒ“κΈ€μ„ 찾을 수 μ—†λŠ” 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void updateCommentFailByNotExist() { + // given + long notExistCommentId = 1; + String updatedContent = "updated content"; + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + + // when & then + assertThatThrownBy(() -> commentService.updateComment(MemberFixture.create(), notExistCommentId, commentUpdateRequest)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  λŒ“κΈ€μ„ 찾을 수 μ—†μ–΄μš”."); + } + + @DisplayName("본인이 달지 μ•Šμ€ λŒ“κΈ€μ— λŒ€ν•΄ μˆ˜μ •μ„ μ‹œλ„ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void updateCommentFailByForbidden() { + // given + Member momentOwner = memberRepository.save(MemberFixture.create("momentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(momentOwner)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, momentOwner)); + + String updatedContent = "updated content"; + CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest(updatedContent); + + // when & then + assertThatThrownBy(() -> commentService.updateComment(unexpectedMember, comment.getId(), commentUpdateRequest)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("본인이 μ“΄ λŒ“κΈ€μ€ μ‚­μ œν•  수 μžˆλ‹€.") + @Test + void deleteComment() { + // given + Member member = memberRepository.save(MemberFixture.create("nickname")); + Memory memory = memoryRepository.save(MemoryFixture.create(member)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, member)); + + // when + commentService.deleteComment(comment.getId(), member); + + // then + assertThat(commentRepository.findById(comment.getId())).isEmpty(); + } + + @DisplayName("본인이 μ“΄ λŒ“κΈ€μ΄ μ•„λ‹ˆλ©΄ μ‚­μ œν•  수 μ—†λ‹€.") + @Test + void deleteCommentFail() { + // given + Member commentOwner = memberRepository.save(MemberFixture.create("commentOwner")); + Member unexpectedMember = memberRepository.save(MemberFixture.create("unexpectedMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(commentOwner)); + Moment moment = momentRepository.save(MomentFixture.create(memory)); + Comment comment = commentRepository.save(CommentFixture.create(moment, commentOwner)); + + // when & then + assertThatThrownBy(() -> commentService.deleteComment(comment.getId(), unexpectedMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } +} diff --git a/backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java b/backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java new file mode 100644 index 000000000..a81664121 --- /dev/null +++ b/backend/src/test/java/com/staccato/config/auth/TokenProviderTest.java @@ -0,0 +1,97 @@ +package com.staccato.config.auth; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; + +import com.staccato.exception.UnauthorizedException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; + +import io.jsonwebtoken.Claims; + +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +public class TokenProviderTest { + @MockBean + private TokenProperties tokenProperties; + @Autowired + private TokenProvider tokenProvider; + @Autowired + private MemberRepository memberRepository; + + private Member member; + private String secretKey = "my-secret-key"; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + when(tokenProperties.secretKey()).thenReturn(secretKey); + + member = memberRepository.save(MemberFixture.create()); + } + + @DisplayName("주어진 μ‚¬μš©μž μ •λ³΄λ‘œ 토큰을 μƒμ„±ν•œλ‹€.") + @Test + public void createToken() { + // given & when + String token = tokenProvider.create(member); + + // then + assertNotNull(token); + } + + @DisplayName("주어진 ν† ν°μ—μ„œ payloadλ₯Ό μΆ”μΆœν•œλ‹€.") + @Test + public void getPayloadFromToken() { + // given + String token = tokenProvider.create(member); + + // when + Claims claims = tokenProvider.getPayload(token); + + // then + assertAll( + () -> assertNotNull(claims), + () -> assertThat(claims.get("id", Long.class)).isEqualTo(member.getId()), + () -> assertThat(claims.get("nickname")).isEqualTo(member.getNickname().getNickname()), + () -> assertThat(claims.get("createdAt")).isEqualTo(member.getCreatedAt().toString()) + ); + } + + @DisplayName("주어진 ν† ν°μ—μ„œ μ‚¬μš©μž μ‹λ³„μžλ₯Ό μΆ”μΆœν•œλ‹€.") + @Test + public void testExtractMemberId() { + // given + String token = tokenProvider.create(member); + + // when + long extractedId = tokenProvider.extractMemberId(token); + + // then + assertThat(extractedId).isEqualTo(member.getId()); + } + + @DisplayName("주어진 토큰이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄ 인증 였λ₯˜κ°€ λ°œμƒν•œλ‹€.") + @Test + public void cannotGetPayloadByInvalidToken() { + // given + String invalidToken = "invalid.token.value"; + + // when & then + assertThatThrownBy(() -> tokenProvider.getPayload(invalidToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("μΈμ¦λ˜μ§€ μ•Šμ€ μ‚¬μš©μžμž…λ‹ˆλ‹€."); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java b/backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java new file mode 100644 index 000000000..f214405ac --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/Member/MemberFixture.java @@ -0,0 +1,13 @@ +package com.staccato.fixture.Member; + +import com.staccato.member.domain.Member; + +public class MemberFixture { + public static Member create() { + return Member.builder().nickname("staccato").build(); + } + + public static Member create(String nickname) { + return Member.builder().nickname(nickname).build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java new file mode 100644 index 000000000..8d40fc130 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryFixture.java @@ -0,0 +1,51 @@ +package com.staccato.fixture.memory; + +import java.time.LocalDate; + +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; + +public class MemoryFixture { + public static Memory create() { + return Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title("2024 여름 νœ΄κ°€") + .description("μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅") + .startAt(LocalDate.of(2024, 7, 1)) + .endAt(LocalDate.of(2024, 7, 10)) + .build(); + } + + public static Memory create(String title) { + return Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title(title) + .description("μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅") + .startAt(LocalDate.of(2024, 7, 1)) + .endAt(LocalDate.of(2024, 7, 10)) + .build(); + } + + public static Memory create(Member member) { + Memory memory = Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title("2024 여름 νœ΄κ°€") + .description("μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅") + .startAt(LocalDate.of(2024, 7, 1)) + .endAt(LocalDate.of(2024, 7, 10)) + .build(); + memory.addMemoryMember(member); + + return memory; + } + + public static Memory create(LocalDate startAt, LocalDate endAt) { + return Memory.builder() + .thumbnailUrl("https://example.com/memorys/geumohrm.jpg") + .title("2024 여름 νœ΄κ°€") + .description("μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅") + .startAt(startAt) + .endAt(endAt) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java new file mode 100644 index 000000000..b1f283833 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryNameResponsesFixture.java @@ -0,0 +1,14 @@ +package com.staccato.fixture.memory; + +import java.util.Arrays; +import java.util.List; + +import com.staccato.memory.domain.Memory; +import com.staccato.memory.service.dto.response.MemoryNameResponses; + +public class MemoryNameResponsesFixture { + public static MemoryNameResponses create(Memory... memory) { + List memories = Arrays.stream(memory).toList(); + return MemoryNameResponses.from(memories); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java new file mode 100644 index 000000000..d9a078693 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryRequestFixture.java @@ -0,0 +1,37 @@ +package com.staccato.fixture.memory; + +import java.time.LocalDate; + +import com.staccato.memory.service.dto.request.MemoryRequest; + +public class MemoryRequestFixture { + public static MemoryRequest create(LocalDate startAt, LocalDate endAt) { + return new MemoryRequest( + "https://example.com/memorys/geumohrm.jpg", + "2023 여름 νœ΄κ°€", + "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", + startAt, + endAt + ); + } + + public static MemoryRequest create(LocalDate startAt, LocalDate endAt, String title) { + return new MemoryRequest( + "https://example.com/memorys/geumohrm.jpg", + title, + "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", + startAt, + endAt + ); + } + + public static MemoryRequest create(String imageUrl, LocalDate startAt, LocalDate endAt) { + return new MemoryRequest( + imageUrl, + "2023 여름 νœ΄κ°€", + "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", + startAt, + endAt + ); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java b/backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java new file mode 100644 index 000000000..e60b2bce1 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/memory/MemoryResponsesFixture.java @@ -0,0 +1,18 @@ +package com.staccato.fixture.memory; + +import java.util.Arrays; +import java.util.List; + +import com.staccato.memory.domain.Memory; +import com.staccato.memory.service.dto.response.MemoryResponse; +import com.staccato.memory.service.dto.response.MemoryResponses; + +public class MemoryResponsesFixture { + public static MemoryResponses create(Memory... memories) { + return new MemoryResponses(convertToMemoryResponses(Arrays.stream(memories).toList())); + } + + private static List convertToMemoryResponses(List memory) { + return memory.stream().map(MemoryResponse::new).toList(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java b/backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java new file mode 100644 index 000000000..9c6fac81e --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/CommentFixture.java @@ -0,0 +1,15 @@ +package com.staccato.fixture.moment; + +import com.staccato.comment.domain.Comment; +import com.staccato.member.domain.Member; +import com.staccato.moment.domain.Moment; + +public class CommentFixture { + public static Comment create(Moment moment, Member member) { + return Comment.builder() + .content("Sample Moment Log") + .moment(moment) + .member(member) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java b/backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java new file mode 100644 index 000000000..07aa254c4 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/MomentDetailResponseFixture.java @@ -0,0 +1,21 @@ +package com.staccato.fixture.moment; + +import java.time.LocalDateTime; +import java.util.List; + +import com.staccato.moment.service.dto.response.MomentDetailResponse; + +public class MomentDetailResponseFixture { + public static MomentDetailResponse create(long momentId, LocalDateTime visitedAt) { + return new MomentDetailResponse( + momentId, + 1, + "memoryTitle", + "placeName", + List.of("https://example1.com.jpg"), + visitedAt, + "happy", + "address", + List.of()); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java b/backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java new file mode 100644 index 000000000..0273648fc --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/MomentFixture.java @@ -0,0 +1,50 @@ +package com.staccato.fixture.moment; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; + +public class MomentFixture { + private static final BigDecimal latitude = new BigDecimal("37.7749"); + private static final BigDecimal longitude = new BigDecimal("-122.4194"); + + public static Moment create(Memory memory) { + return Moment.builder() + .visitedAt(LocalDateTime.of(2024, 7, 1, 10, 0)) + .placeName("placeName") + .latitude(latitude) + .longitude(longitude) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build(); + } + + public static Moment create(Memory memory, LocalDateTime visitedAt) { + return Moment.builder() + .visitedAt(visitedAt) + .placeName("placeName") + .latitude(latitude) + .longitude(longitude) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build(); + } + + public static Moment createWithImages(Memory memory, LocalDateTime visitedAt, MomentImages momentImages) { + return Moment.builder() + .visitedAt(visitedAt) + .placeName("placeName") + .latitude(latitude) + .longitude(longitude) + .address("address") + .memory(memory) + .momentImages(momentImages) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java b/backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java new file mode 100644 index 000000000..2194dd463 --- /dev/null +++ b/backend/src/test/java/com/staccato/fixture/moment/MomentLocationResponsesFixture.java @@ -0,0 +1,16 @@ +package com.staccato.fixture.moment; + +import java.math.BigDecimal; +import java.util.List; + +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +public class MomentLocationResponsesFixture { + public static MomentLocationResponses create() { + return new MomentLocationResponses( + List.of(new MomentLocationResponse(1, BigDecimal.ONE, BigDecimal.ZERO), + new MomentLocationResponse(2, BigDecimal.ONE, BigDecimal.ZERO), + new MomentLocationResponse(3, BigDecimal.ONE, BigDecimal.ZERO))); + } +} diff --git a/backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java b/backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java new file mode 100644 index 000000000..61f5cbd6a --- /dev/null +++ b/backend/src/test/java/com/staccato/image/controller/ImageControllerTest.java @@ -0,0 +1,54 @@ +package com.staccato.image.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.image.service.ImageService; +import com.staccato.image.service.dto.ImageUrlResponse; +import com.staccato.member.domain.Member; + +@WebMvcTest(controllers = ImageController.class) +public class ImageControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private ImageService imageService; + @MockBean + private AuthService authService; + + @DisplayName("사진을 ν•œ μž₯ μ—…λ‘œλ“œν•˜κ³  S3 url을 κ°€μ Έμ˜¬ 수 μžˆλ‹€.") + @Test + void uploadFileTest() throws Exception { + // given + MockMultipartFile image = new MockMultipartFile("imageFile", new byte[0]); + ImageUrlResponse imageUrlResponse = new ImageUrlResponse("imageUrl"); + when(authService.extractFromToken(anyString())).thenReturn(Member.builder().nickname("staccato").build()); + when(imageService.uploadImage(any())).thenReturn(imageUrlResponse); + + // when & then + mockMvc.perform(multipart("/images") + .file(image) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andExpect(content().json(objectMapper.writeValueAsString(imageUrlResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java b/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java new file mode 100644 index 000000000..eaf25dd35 --- /dev/null +++ b/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java @@ -0,0 +1,16 @@ +package com.staccato.image.infrastructure; + +public class FakeS3ObjectClient extends S3ObjectClient { + public FakeS3ObjectClient() { + super("fakeBuket", "fakeEndPoint", "fakeCloudFrontEndPoint"); + } + + @Override + public void putS3Object(String objectKey, String contentType, byte[] imageBytes) { + } + + @Override + public String getUrl(String keyName) { + return "fakeUrl"; + } +} diff --git a/backend/src/test/java/com/staccato/member/domain/NicknameTest.java b/backend/src/test/java/com/staccato/member/domain/NicknameTest.java new file mode 100644 index 000000000..0a80bc057 --- /dev/null +++ b/backend/src/test/java/com/staccato/member/domain/NicknameTest.java @@ -0,0 +1,36 @@ +package com.staccato.member.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; + +class NicknameTest { + @DisplayName("μœ νš¨ν•œ λ‹‰λ„€μž„μ„ μƒμ„±ν•œλ‹€.") + @Test + void CreateNickname() { + assertThatNoException().isThrownBy(() -> new Nickname("κ°€γ„±γ…γ…£γ…Ž.AZ1az_")); + } + + @DisplayName("λ‹‰λ„€μž„μ˜ ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμ„ 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚¨λ‹€.") + @Test + void cannotCreateNicknameByInvalidFormat() { + assertThatThrownBy(() -> new Nickname("//")) + .isInstanceOf(StaccatoException.class) + .hasMessage("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ λ‹‰λ„€μž„ ν˜•μ‹μž…λ‹ˆλ‹€."); + } + + @DisplayName("λ‹‰λ„€μž„ 맨 μ•ž, λ’€ 곡백은 μ œκ±°λœλ‹€.") + @Test + void createNicknameAfterTrim() { + // given + Nickname nickname = new Nickname(" staccato "); + + // when & then + assertThat(nickname.getNickname()).isEqualTo("staccato"); + } +} diff --git a/backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java b/backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java new file mode 100644 index 000000000..ce269d163 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/controller/MemoryControllerTest.java @@ -0,0 +1,269 @@ +package com.staccato.memory.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.memory.MemoryNameResponsesFixture; +import com.staccato.fixture.memory.MemoryRequestFixture; +import com.staccato.fixture.memory.MemoryResponsesFixture; +import com.staccato.member.domain.Member; +import com.staccato.memory.service.MemoryService; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; + +@WebMvcTest(MemoryController.class) +class MemoryControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private MemoryService memoryService; + @MockBean + private AuthService authService; + + static Stream memoryRequestProvider() { + return Stream.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 νœ΄κ°€", "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + new MemoryRequest(null, "2023 여름 νœ΄κ°€", "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 νœ΄κ°€", null, LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)) + ); + } + + static Stream invalidMemoryRequestProvider() { + return Stream.of( + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", null, "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "μΆ”μ–΅ 제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", " ", "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "μΆ”μ–΅ 제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "κ°€".repeat(31), "μΉœκ΅¬λ“€κ³Ό ν•¨κ»˜ν•œ 여름 νœ΄κ°€ μΆ”μ–΅", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "제λͺ©μ€ 곡백 포함 30자 μ΄ν•˜λ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MemoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 νœ΄κ°€", "κ°€".repeat(501), LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "λ‚΄μš©μ˜ μ΅œλŒ€ ν—ˆμš© κΈ€μžμˆ˜λŠ” 곡백 포함 500μžμž…λ‹ˆλ‹€." + ) + ); + } + + @DisplayName("μ‚¬μš©μžκ°€ μΆ”μ–΅ 정보λ₯Ό μž…λ ₯ν•˜λ©΄, μƒˆλ‘œμš΄ 좔얡을 μƒμ„±ν•œλ‹€.") + @ParameterizedTest + @MethodSource("memoryRequestProvider") + void createMemory(MemoryRequest memoryRequest) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(memoryService.createMemory(any(), any())).thenReturn(new MemoryIdResponse(1)); + + // when & then + mockMvc.perform(post("/memories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/memories/1")) + .andExpect(jsonPath("$.memoryId").value(1)); + } + + @DisplayName("μ‚¬μš©μžκ°€ 잘λͺ»λœ ν˜•μ‹μœΌλ‘œ 정보λ₯Ό μž…λ ₯ν•˜λ©΄, 좔얡을 생성할 수 μ—†λ‹€.") + @ParameterizedTest + @MethodSource("invalidMemoryRequestProvider") + void failCreateMemory(MemoryRequest memoryRequest, String expectedMessage) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(memoryService.createMemory(any(MemoryRequest.class), any(Member.class))).thenReturn(new MemoryIdResponse(1)); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(post("/memories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ‚¬μš©μžκ°€ λͺ¨λ“  μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€.") + @Test + void readAllMemory() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MemoryResponses memoryResponses = MemoryResponsesFixture.create(MemoryFixture.create()); + when(memoryService.readAllMemories(any(Member.class))).thenReturn(memoryResponses); + + // when & then + mockMvc.perform(get("/memories") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memoryResponses))); + } + + @DisplayName("νŠΉμ • λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜κ³  μžˆλŠ” λͺ¨λ“  μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€.") + @Test + void readAllMemoryIncludingDate() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MemoryNameResponses memoryNameResponses = MemoryNameResponsesFixture.create(MemoryFixture.create()); + when(memoryService.readAllMemoriesIncludingDate(any(Member.class), any())).thenReturn(memoryNameResponses); + String currentDate = "2024-07-01"; + + // when & then + mockMvc.perform(get("/memories/candidates") + .header(HttpHeaders.AUTHORIZATION, "token") + .param("currentDate", currentDate)) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memoryNameResponses))); + } + + @DisplayName("잘λͺ»λœ λ‚ μ§œ ν˜•μ‹μœΌλ‘œ μΆ”μ–΅ λͺ©λ‘ 쑰회λ₯Ό μ‹œλ„ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"2024.07.01", "2024-07", "2024", "a"}) + void cannotReadAllMemoryByInvalidDateFormat(String currentDate) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 쿼리 슀트링 ν˜•μ‹μž…λ‹ˆλ‹€."); + + // when & then + mockMvc.perform(get("/memories/candidates") + .header(HttpHeaders.AUTHORIZATION, "token") + .param("currentDate", currentDate)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ‚¬μš©μžκ°€ νŠΉμ • 좔얡을 μ‘°νšŒν•œλ‹€.") + @Test + void readMemory() throws Exception { + // given + long memoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MemoryDetailResponse memoryDetailResponse = new MemoryDetailResponse(MemoryFixture.create(), List.of()); + when(memoryService.readMemoryById(anyLong(), any(Member.class))).thenReturn(memoryDetailResponse); + + // when & then + mockMvc.perform(get("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memoryDetailResponse))); + } + + @DisplayName("μ ν•©ν•œ κ²½λ‘œλ³€μˆ˜μ™€ 데이터λ₯Ό 톡해 μŠ€νƒ€μΉ΄ν†  μˆ˜μ •μ— μ„±κ³΅ν•œλ‹€.") + @ParameterizedTest + @MethodSource("memoryRequestProvider") + void updateMemory(MemoryRequest memoryRequest) throws Exception { + // given + long memoryId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest).getBytes())) + .andExpect(status().isOk()); + } + + @DisplayName("μ‚¬μš©μžκ°€ 잘λͺ»λœ ν˜•μ‹μœΌλ‘œ 정보λ₯Ό μž…λ ₯ν•˜λ©΄, 좔얡을 μˆ˜μ •ν•  수 μ—†λ‹€.") + @ParameterizedTest + @MethodSource("invalidMemoryRequestProvider") + void failUpdateMemory(MemoryRequest memoryRequest, String expectedMessage) throws Exception { + // given + long memoryId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(put("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest).getBytes())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ ν•©ν•˜μ§€ μ•Šμ€ κ²½λ‘œλ³€μˆ˜μ˜ 경우 μΆ”μ–΅ μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void failUpdateMemoryByInvalidPath() throws Exception { + // given + long memoryId = 0L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)); + + // when & then + mockMvc.perform(put("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memoryRequest).getBytes())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ‚¬μš©μžκ°€ μΆ”μ–΅ μ‹λ³„μžλ‘œ 좔얡을 μ‚­μ œν•œλ‹€.") + @Test + void deleteMemory() throws Exception { + // given + long memoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/memories/{memoryId}", memoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("μ‚¬μš©μžκ°€ 잘λͺ»λœ μΆ”μ–΅ μ‹λ³„μžλ‘œ μ‚­μ œν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotDeleteMemoryByInvalidId() throws Exception { + // given + long invalidId = 0; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + + // when & then + mockMvc.perform(delete("/memories/{memoryId}", invalidId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java b/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java new file mode 100644 index 000000000..8a2d34f30 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java @@ -0,0 +1,59 @@ +package com.staccato.memory.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.moment.domain.Moment; + +class MemoryTest { + @DisplayName("μΆ”μ–΅ 생성 μ‹œ 제λͺ©μ—λŠ” μ•žλ’€ 곡백이 μž˜λ¦°λ‹€.") + @Test + void trimMemoryTitle() { + // given + String expectedTitle = "title"; + + // when + Memory memory = Memory.builder().title(" title ").build(); + + // then + assertThat(memory.getTitle()).isEqualTo(expectedTitle); + } + + @DisplayName("좔얡을 μˆ˜μ • μ‹œ κΈ°μ‘΄ μŠ€νƒ€μΉ΄ν†  기둝 λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜μ§€ μ•ŠλŠ” 경우 μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void validateDuration() { + // given + Memory memory = MemoryFixture.create(LocalDate.now(), LocalDate.now().plusDays(1)); + Memory updatedMemory = MemoryFixture.create(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2)); + Moment moment = MomentFixture.create(memory, LocalDateTime.now()); + + // when & then + assertThatThrownBy(() -> memory.update(updatedMemory, List.of(moment))) + .isInstanceOf(StaccatoException.class) + .hasMessage("기간이 이미 μ‘΄μž¬ν•˜λŠ” μŠ€νƒ€μΉ΄ν† λ₯Ό ν¬ν•¨ν•˜μ§€ μ•Šμ•„μš”. λ‹€μ‹œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + } + + @DisplayName("주어진 λ¬Έμžμ—΄κ³Ό 제λͺ©μ΄ κ°™μœΌλ©΄ 거짓을 λ°˜ν™˜ν•œλ‹€.") + @Test + void isNotSameTitle(){ + // given + String title = "title"; + Memory memory = Memory.builder().title(title).build(); + + // when + boolean result = memory.isNotSameTitle(title); + + // then + assertThat(result).isFalse(); + } +} diff --git a/backend/src/test/java/com/staccato/memory/domain/TermTest.java b/backend/src/test/java/com/staccato/memory/domain/TermTest.java new file mode 100644 index 000000000..1467baf91 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/domain/TermTest.java @@ -0,0 +1,69 @@ +package com.staccato.memory.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; + +class TermTest { + @DisplayName("끝 λ‚ μ§œλŠ” μ‹œμž‘ λ‚ μ§œλ³΄λ‹€ μ•žμ„€ 수 μ—†λ‹€.") + @Test + void validateDate() { + assertThatCode(() -> new Term(LocalDate.now().plusDays(1), LocalDate.now())) + .isInstanceOf(StaccatoException.class) + .hasMessage("끝 λ‚ μ§œκ°€ μ‹œμž‘ λ‚ μ§œλ³΄λ‹€ μ•žμ„€ 수 μ—†μ–΄μš”."); + } + + @DisplayName("νŠΉμ • λ‚ μ§œκ°€ μΆ”μ–΅ 기간에 μ†ν•˜μ§€ μ•ŠμœΌλ©΄ 참을 λ°˜ν™˜ν•œλ‹€.") + @Test + void isOutOfTerm() { + // given + Term term = new Term(LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)); + + // when & then + assertThat(term.doesNotContain(LocalDateTime.of(2023, 7, 11, 10, 0))).isTrue(); + } + + @DisplayName("νŠΉμ • λ‚ μ§œκ°€ μΆ”μ–΅ 기간에 μ†ν•˜λ©΄ 거짓을 λ°˜ν™˜ν•œλ‹€.") + @Test + void isInTerm() { + // given + Term term = new Term(LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)); + + // when & then + assertThat(term.doesNotContain(LocalDateTime.of(2023, 7, 1, 10, 0))).isFalse(); + } + + @DisplayName("μΆ”μ–΅ 기간이 μ—†λ‹€λ©΄, μ–΄λ–€ λ‚ μ§œλ“  거짓을 λ°˜ν™˜ν•œλ‹€.") + @Test + void isNoTerm() { + // given + Term term = new Term(null, null); + + // when & then + assertThat(term.doesNotContain(LocalDateTime.of(2023, 7, 11, 10, 0))).isFalse(); + } + + @DisplayName("끝 λ‚ μ§œλŠ” μžˆλŠ”λ°, μ‹œμž‘ λ‚ μ§œκ°€ λˆ„λ½λ˜λ©΄ μ˜ˆμ™Έλ₯Ό λ°œμƒν•œλ‹€.") + @Test + void cannotCreateTermByNoStartAt() { + assertThatThrownBy(() -> new Term(null, LocalDate.now())) + .isInstanceOf(StaccatoException.class) + .hasMessage("μΆ”μ–΅ μ‹œμž‘ λ‚ μ§œμ™€ 끝 λ‚ μ§œλ₯Ό λͺ¨λ‘ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + } + + @DisplayName("μ‹œμž‘ λ‚ μ§œλŠ” μžˆλŠ”λ°, 끝 λ‚ μ§œκ°€ λˆ„λ½λ˜λ©΄ μ˜ˆμ™Έλ₯Ό λ°œμƒν•œλ‹€.") + @Test + void cannotCreateTermByNoEndAt() { + assertThatThrownBy(() -> new Term(LocalDate.now(), null)) + .isInstanceOf(StaccatoException.class) + .hasMessage("μΆ”μ–΅ μ‹œμž‘ λ‚ μ§œμ™€ 끝 λ‚ μ§œλ₯Ό λͺ¨λ‘ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + } +} diff --git a/backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java b/backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java new file mode 100644 index 000000000..9061e531b --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/repository/MemoryMemberRepositoryTest.java @@ -0,0 +1,62 @@ +package com.staccato.memory.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; + +@DataJpaTest +class MemoryMemberRepositoryTest { + @Autowired + private MemoryMemberRepository memoryMemberRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryRepository memoryRepository; + + @DisplayName("μ‚¬μš©μž μ‹λ³„μžμ™€ λ‚ μ§œλ‘œ μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€.") + @Test + void findAllByMemberIdAndDate() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 30), LocalDate.of(2023, 12, 30))); + Memory memory2 = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 31), LocalDate.of(2023, 12, 31))); + memoryMemberRepository.save(new MemoryMember(member, memory)); + memoryMemberRepository.save(new MemoryMember(member, memory2)); + + // when + List result = memoryMemberRepository.findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(member.getId(), LocalDate.of(2023, 12, 31)); + + // then + assertThat(result).hasSize(1); + } + + @DisplayName("μ‚¬μš©μž μ‹λ³„μžμ™€ λ‚ μ§œλ‘œ μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•  λ•Œ 좔얡에 κΈ°ν•œμ΄ 없을 경우 ν•¨κ»˜ μ‘°νšŒν•œλ‹€.") + @Test + void findAllByMemberIdAndDateWhenNull() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 30), LocalDate.of(2023, 12, 30))); + Memory memory2 = memoryRepository.save(MemoryFixture.create(null, null)); + memoryMemberRepository.save(new MemoryMember(member, memory)); + memoryMemberRepository.save(new MemoryMember(member, memory2)); + + // when + List result = memoryMemberRepository.findAllByMemberIdAndIncludingDateOrderByCreatedAtDesc(member.getId(), LocalDate.of(2023, 12, 30)); + + // then + assertThat(result).hasSize(2); + } +} diff --git a/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java b/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java new file mode 100644 index 000000000..13739d7df --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java @@ -0,0 +1,317 @@ +package com.staccato.memory.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryRequestFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; +import com.staccato.memory.repository.MemoryMemberRepository; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MomentResponse; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.repository.MomentRepository; + +class MemoryServiceTest extends ServiceSliceTest { + @Autowired + private MemoryService memoryService; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryMemberRepository memoryMemberRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MomentRepository momentRepository; + + static Stream dateProvider() { + return Stream.of( + Arguments.of(LocalDate.of(2024, 7, 1), 2), + Arguments.of(LocalDate.of(2024, 7, 2), 1) + ); + } + + static Stream updateMemoryProvider() { + return Stream.of( + Arguments.of( + MemoryRequestFixture.create(null, LocalDate.of(2024, 8, 1), LocalDate.of(2024, 8, 10)), null), + Arguments.of( + MemoryRequestFixture.create("imageUrl", LocalDate.of(2024, 8, 1), LocalDate.of(2024, 8, 10)), "imageUrl")); + } + + @DisplayName("μΆ”μ–΅ 정보λ₯Ό 기반으둜, 좔얡을 μƒμ„±ν•˜κ³  μž‘μ„±μžλ₯Ό μ €μž₯ν•œλ‹€.") + @Test + void createMemory() { + // given + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)); + Member member = memberRepository.save(MemberFixture.create()); + + // when + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest, member); + MemoryMember memoryMember = memoryMemberRepository.findAllByMemberIdOrderByMemoryCreatedAtDesc(member.getId()) + .get(0); + + // then + assertAll( + () -> assertThat(memoryMember.getMember().getId()).isEqualTo(member.getId()), + () -> assertThat(memoryMember.getMemory().getId()).isEqualTo(memoryIdResponse.memoryId()) + ); + } + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” μΆ”μ–΅ μ΄λ¦„μœΌλ‘œ 좔얡을 생성할 수 μ—†λ‹€.") + @Test + void cannotCreateMemoryByDuplicatedTitle() { + // given + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)); + Member member = memberRepository.save(MemberFixture.create()); + memoryService.createMemory(memoryRequest, member); + + // when & then + assertThatThrownBy(() -> memoryService.createMemory(memoryRequest, member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("같은 이름을 가진 좔얡이 μžˆμ–΄μš”. λ‹€λ₯Έ μ΄λ¦„μœΌλ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + } + + @DisplayName("ν˜„μž¬ λ‚ μ§œλ₯Ό ν¬ν•¨ν•˜λŠ” λͺ¨λ“  μΆ”μ–΅ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€.") + @MethodSource("dateProvider") + @ParameterizedTest + void readAllMemories(LocalDate currentDate, int expectedSize) { + // given + Member member = memberRepository.save(MemberFixture.create()); + memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 1), "title1"), member); + memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 2), "title2"), member); + + // when + MemoryNameResponses memoryNameResponses = memoryService.readAllMemoriesIncludingDate(member, currentDate); + + // then + assertThat(memoryNameResponses.memories()).hasSize(expectedSize); + } + + @DisplayName("νŠΉμ • 좔얡을 μ‘°νšŒν•œλ‹€.") + @Test + void readMemoryById() { + // given + Member member = memberRepository.save(MemberFixture.create()); + + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryDetailResponse.memoryId()).isEqualTo(memoryIdResponse.memoryId()), + () -> assertThat(memoryDetailResponse.mates()).hasSize(1) + ); + } + + @DisplayName("본인 것이 μ•„λ‹Œ νŠΉμ • 좔얡을 μ‘°νšŒν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotReadMemoryByIdIfNotOwner() { + // given + Member member = memberRepository.save(MemberFixture.create("member")); + Member otherMember = memberRepository.save(MemberFixture.create("otherMember")); + + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when & then + assertThatThrownBy(() -> memoryService.readMemoryById(memoryIdResponse.memoryId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("νŠΉμ • 좔얡을 μ‘°νšŒν•˜λ©΄ μŠ€νƒ€μΉ΄ν† λŠ” 였래된 순으둜 λ°˜ν™˜ν•œλ‹€.") + @Test + void readMemoryByIdOrderByVisitedAt() { + // given + Member member = memberRepository.save(MemberFixture.create()); + + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + Moment firstMoment = saveMoment(LocalDateTime.of(2023, 7, 1, 10, 0), memoryIdResponse.memoryId()); + Moment secondMoment = saveMoment(LocalDateTime.of(2023, 7, 1, 10, 10), memoryIdResponse.memoryId()); + Moment lastMoment = saveMoment(LocalDateTime.of(2023, 7, 5, 9, 0), memoryIdResponse.memoryId()); + + // when + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryDetailResponse.memoryId()).isEqualTo(memoryIdResponse.memoryId()), + () -> assertThat(memoryDetailResponse.moments()).hasSize(3), + () -> assertThat(memoryDetailResponse.moments().stream().map(MomentResponse::momentId).toList()) + .containsExactly(firstMoment.getId(), secondMoment.getId(), lastMoment.getId()) + ); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 좔얡을 μ‘°νšŒν•˜λ €κ³  ν•  경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failReadMemory() { + // given + Member member = memberRepository.save(MemberFixture.create()); + long unknownId = 1; + + // when & then + assertThatThrownBy(() -> memoryService.readMemoryById(unknownId, member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("μš”μ²­ν•˜μ‹  좔얡을 찾을 수 μ—†μ–΄μš”."); + } + + @DisplayName("μΆ”μ–΅ 정보λ₯Ό 기반으둜, 좔얡을 μˆ˜μ •ν•œλ‹€.") + @MethodSource("updateMemoryProvider") + @ParameterizedTest + void updateMemory(MemoryRequest updatedMemory, String expected) { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when + memoryService.updateMemory(updatedMemory, memoryResponse.memoryId(), member); + Memory foundedMemory = memoryRepository.findById(memoryResponse.memoryId()).get(); + + // then + assertAll( + () -> assertThat(foundedMemory.getId()).isEqualTo(memoryResponse.memoryId()), + () -> assertThat(foundedMemory.getTitle()).isEqualTo(updatedMemory.memoryTitle()), + () -> assertThat(foundedMemory.getDescription()).isEqualTo(updatedMemory.description()), + () -> assertThat(foundedMemory.getTerm().getStartAt()).isEqualTo(updatedMemory.startAt()), + () -> assertThat(foundedMemory.getTerm().getEndAt()).isEqualTo(updatedMemory.endAt()), + () -> assertThat(foundedMemory.getThumbnailUrl()).isEqualTo(expected) + ); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 좔얡을 μˆ˜μ •ν•˜λ € ν•  경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failUpdateMemory() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)); + + // when & then + assertThatThrownBy(() -> memoryService.updateMemory(memoryRequest, 1L, member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("μš”μ²­ν•˜μ‹  좔얡을 찾을 수 μ—†μ–΄μš”."); + } + + @DisplayName("본인 것이 μ•„λ‹Œ 좔얡을 μˆ˜μ •ν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotUpdateMemoryIfNotOwner() { + // given + Member member = memberRepository.save(MemberFixture.create("member")); + Member otherMember = memberRepository.save(MemberFixture.create("otherMember")); + MemoryRequest updatedMemory = MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when & then + assertThatThrownBy(() -> memoryService.updateMemory(updatedMemory, memoryIdResponse.memoryId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("본래 ν•΄λ‹Ή μΆ”μ–΅μ˜ 이름과 λ™μΌν•œ μ΄λ¦„μœΌλ‘œ 좔얡을 μˆ˜μ •ν•  수 μžˆλ‹€.") + @Test + void updateMemoryByOriginTitle() { + // given + MemoryRequest memoryRequest = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10)); + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest, member); + + // when & then + assertThatNoException().isThrownBy(() -> memoryService.updateMemory(memoryRequest, memoryIdResponse.memoryId(), member)); + } + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” μ΄λ¦„μœΌλ‘œ 좔얡을 μˆ˜μ •ν•  수 μ—†λ‹€.") + @Test + void cannotUpdateMemoryByDuplicatedTitle() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryRequest memoryRequest1 = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10), "existingTitle"); + memoryService.createMemory(memoryRequest1, member); + MemoryRequest memoryRequest2 = MemoryRequestFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10), "otherTitle"); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(memoryRequest2, member); + + // when & then + assertThatThrownBy(() -> memoryService.updateMemory(memoryRequest1, memoryIdResponse.memoryId(), member)) + .isInstanceOf(StaccatoException.class) + .hasMessage("같은 이름을 가진 좔얡이 μžˆμ–΄μš”. λ‹€λ₯Έ μ΄λ¦„μœΌλ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”."); + ; + } + + @DisplayName("μΆ”μ–΅ 식별값을 톡해 좔얡을 μ‚­μ œν•œλ‹€.") + @Test + void deleteMemory() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when + memoryService.deleteMemory(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryRepository.findById(memoryIdResponse.memoryId())).isEmpty(), + () -> assertThat(memoryMemberRepository.findAll()).hasSize(0) + ); + } + + @DisplayName("좔얡을 μ‚­μ œν•˜λ©΄ μ†ν•œ μŠ€νƒ€μΉ΄ν† λ“€λ„ ν•¨κ»˜ μ‚­μ œλœλ‹€.") + @Test + void deleteMemoryWithMoment() { + // given + Member member = memberRepository.save(MemberFixture.create()); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + saveMoment(LocalDateTime.of(2023, 7, 2, 10, 10), memoryIdResponse.memoryId()); + + // when + memoryService.deleteMemory(memoryIdResponse.memoryId(), member); + + // then + assertAll( + () -> assertThat(memoryRepository.findById(memoryIdResponse.memoryId())).isEmpty(), + () -> assertThat(memoryMemberRepository.findAll()).hasSize(0), + () -> assertThat(momentRepository.findAll()).isEmpty() + ); + } + + private Moment saveMoment(LocalDateTime visitedAt, long memoryId) { + return momentRepository.save(MomentFixture.create(memoryRepository.findById(memoryId).get(), visitedAt)); + } + + @DisplayName("본인 것이 μ•„λ‹Œ μΆ”μ–΅ 상세λ₯Ό μ‚­μ œν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotDeleteMemoryIfNotOwner() { + // given + Member member = memberRepository.save(MemberFixture.create("member")); + Member otherMember = memberRepository.save(MemberFixture.create("otherMember")); + MemoryIdResponse memoryIdResponse = memoryService.createMemory(MemoryRequestFixture.create(LocalDate.of(2023, 7, 1), LocalDate.of(2024, 7, 10)), member); + + // when & then + assertThatThrownBy(() -> memoryService.deleteMemory(memoryIdResponse.memoryId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } +} diff --git a/backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java b/backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java new file mode 100644 index 000000000..0b5036827 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/service/dto/request/MemoryRequestTest.java @@ -0,0 +1,30 @@ +package com.staccato.memory.service.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MemoryRequestTest { + @DisplayName("MemoryRequestλ₯Ό 생성할 λ•Œ titleμ—λŠ” trim이 μ μš©λœλ‹€.") + @Test + void trimTitle() { + // given + String memoryTitle = " title "; + String expectedTitle = "title"; + + // when + MemoryRequest memoryRequest = new MemoryRequest( + "thumbnail", + memoryTitle, + "description", + LocalDate.of(2024, 8, 22), + LocalDate.of(2024, 8, 22) + ); + + // then + assertThat(memoryRequest.memoryTitle()).isEqualTo(expectedTitle); + } +} diff --git a/backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java b/backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java new file mode 100644 index 000000000..1e55a1bd9 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/controller/MomentControllerTest.java @@ -0,0 +1,356 @@ +package com.staccato.moment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.auth.service.AuthService; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.moment.MomentDetailResponseFixture; +import com.staccato.fixture.moment.MomentLocationResponsesFixture; +import com.staccato.member.domain.Member; +import com.staccato.moment.service.MomentService; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +@WebMvcTest(controllers = MomentController.class) +class MomentControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private MomentService momentService; + @MockBean + private AuthService authService; + + static Stream invalidMomentRequestProvider() { + return Stream.of( + Arguments.of( + new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 0L, List.of("https://example.com/images/namsan_tower.jpg")), + "μΆ”μ–΅ μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€." + ), + Arguments.of( + new MomentRequest(null, "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν†  제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MomentRequest(" ", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν†  제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MomentRequest("κ°€".repeat(31), "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν†  제λͺ©μ€ 곡백 포함 30자 μ΄ν•˜λ‘œ μ„€μ •ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MomentRequest("placeName", "address", null, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν† μ˜ μœ„λ„λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MomentRequest("placeName", "address", BigDecimal.ONE, null, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν† μ˜ 경도λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MomentRequest("placeName", null, BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν† μ˜ μ£Όμ†Œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”." + ), + Arguments.of( + new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, null, 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "μŠ€νƒ€μΉ΄ν†  λ‚ μ§œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”." + ) + ); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  생성 μ‹œ 사진 5μž₯κΉŒμ§€λŠ” 첨뢀 κ°€λŠ₯ν•˜λ‹€.") + @Test + void createMoment() throws Exception { + // given + MomentRequest momentRequest = new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg")); + String momentRequestJson = objectMapper.writeValueAsString(momentRequest); + MomentIdResponse momentIdResponse = new MomentIdResponse(1L); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/moments/1")) + .andExpect(content().json(objectMapper.writeValueAsString(momentIdResponse))); + } + + @DisplayName("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ λ‚ μ§œ ν˜•μ‹μœΌλ‘œ μŠ€νƒ€μΉ΄ν†  생성을 μš”μ²­ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failCreateMomentWithInvalidVisitedAt() throws Exception { + // given + String momentRequestJson = """ + { + "placeName": "런던 λ°•λ¬Όκ΄€", + "address": "Great Russell St, London WC1B 3DG", + "latitude": 51.51978412729915, + "longitude": -0.12712788587027796, + "visitedAt": "2024/07/27T10:00:00", + "memoryId": 1 + } + """; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("μš”μ²­ 본문을 읽을 수 μ—†μŠ΅λ‹ˆλ‹€. μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ 데이터λ₯Ό μ œκ³΅ν•΄μ£Όμ„Έμš”.")); + } + + @DisplayName("사진이 5μž₯을 μ΄ˆκ³Όν•˜λ©΄ μŠ€νƒ€μΉ΄ν†  생성에 μ‹€νŒ¨ν•œλ‹€.") + @Test + void failCreateMomentByImageCount() throws Exception { + // given + MomentRequest momentRequest = new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg", + "https://example.com/images/namsan_tower6.jpg")); + String momentRequestJson = objectMapper.writeValueAsString(momentRequest); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "사진은 5μž₯κΉŒμ§€λ§Œ μΆ”κ°€ν•  수 μžˆμ–΄μš”."); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ‚¬μš©μžκ°€ 잘λͺ»λœ μš”μ²­ ν˜•μ‹μœΌλ‘œ 정보λ₯Ό μž…λ ₯ν•˜λ©΄, μŠ€νƒ€μΉ΄ν† λ₯Ό 생성할 수 μ—†λ‹€.") + @ParameterizedTest + @MethodSource("invalidMomentRequestProvider") + void failCreateMoment(MomentRequest momentRequest, String expectedMessage) throws Exception { + // given + String momentRequestJson = objectMapper.writeValueAsString(momentRequest); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/moments") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(momentRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  λͺ©λ‘ μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void readAllMoment() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MomentLocationResponses responses = MomentLocationResponsesFixture.create(); + when(momentService.readAllMoment(any(Member.class))).thenReturn(responses); + + // when & then + mockMvc.perform(get("/moments") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(responses))); + } + + @DisplayName("μ ν•©ν•œ κ²½λ‘œλ³€μˆ˜λ₯Ό 톡해 μŠ€νƒ€μΉ΄ν†  μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void readMomentById() throws Exception { + // given + long momentId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MomentDetailResponse response = MomentDetailResponseFixture.create(momentId, LocalDateTime.now()); + when(momentService.readMomentById(anyLong(), any(Member.class))).thenReturn(response); + + // when & then + mockMvc.perform(get("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))); + } + + @DisplayName("μ ν•©ν•˜μ§€ μ•Šμ€ κ²½λ‘œλ³€μˆ˜μ˜ 경우 μŠ€νƒ€μΉ΄ν†  μ‘°νšŒμ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void failReadMomentById() throws Exception { + // given + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + + // when & then + mockMvc.perform(get("/moments/{momentId}", 0)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ ν•©ν•œ κ²½λ‘œλ³€μˆ˜λ₯Ό 톡해 μŠ€νƒ€μΉ΄ν†  μˆ˜μ •μ— μ„±κ³΅ν•œλ‹€.") + @Test + void updateMomentById() throws Exception { + // given + long momentId = 1L; + MomentUpdateRequest updateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isOk()); + } + + @DisplayName("μΆ”κ°€ν•˜λ €λŠ” 사진이 5μž₯이 λ„˜λŠ”λ‹€λ©΄ μŠ€νƒ€μΉ΄ν†  μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void failUpdateMomentByImagesSize() throws Exception { + // given + long momentId = 1L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "사진은 5μž₯κΉŒμ§€λ§Œ μΆ”κ°€ν•  수 μžˆμ–΄μš”."); + MomentUpdateRequest updateRequest = new MomentUpdateRequest("placeName", + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg", + "https://example.com/images/namsan_tower6.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μ ν•©ν•˜μ§€ μ•Šμ€ κ²½λ‘œλ³€μˆ˜μ˜ 경우 μŠ€νƒ€μΉ΄ν†  μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void failUpdateMomentById() throws Exception { + // given + long momentId = 0L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + MomentUpdateRequest updateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  μˆ˜μ • μ‹œ μž₯μ†Œ 이름을 μž…λ ₯ν•˜μ§€ μ•Šμ€ 경우 μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void failUpdateMomentByPlaceName() throws Exception { + // given + long momentId = 1L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μŠ€νƒ€μΉ΄ν†  제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + MomentUpdateRequest updateRequest = new MomentUpdateRequest(null, List.of("https://example1.com.jpg")); + String updateRequestJson = objectMapper.writeValueAsString(updateRequest); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν† λ₯Ό μ‚­μ œν•œλ‹€.") + @Test + void deleteMomentById() throws Exception { + // given + long momentId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("μ–‘μˆ˜κ°€ μ•„λ‹Œ id둜 μŠ€νƒ€μΉ΄ν† λ₯Ό μ‚­μ œν•  수 μ—†λ‹€.") + @Test + void failDeleteMomentById() throws Exception { + // given + long momentId = 0L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "μŠ€νƒ€μΉ΄ν†  μ‹λ³„μžλŠ” μ–‘μˆ˜λ‘œ 이루어져야 ν•©λ‹ˆλ‹€."); + + // when & then + mockMvc.perform(delete("/moments/{momentId}", momentId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("κΈ°λΆ„ 선택을 ν•˜μ§€ μ•Šμ€ 경우 κΈ°λΆ„ μˆ˜μ •μ— μ‹€νŒ¨ν•œλ‹€.") + @Test + void failUpdateMomentFeelingById() throws Exception { + // given + long momentId = 1L; + FeelingRequest feelingRequest = new FeelingRequest(null); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "κΈ°λΆ„ 값을 μž…λ ₯ν•΄μ£Όμ„Έμš”."); + + // when & then + mockMvc.perform(post("/moments/{momentId}/feeling", momentId) + .header(HttpHeaders.AUTHORIZATION, "token") + .content(objectMapper.writeValueAsString(feelingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/moment/domain/FeelingTest.java b/backend/src/test/java/com/staccato/moment/domain/FeelingTest.java new file mode 100644 index 000000000..5080edea6 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/domain/FeelingTest.java @@ -0,0 +1,26 @@ +package com.staccato.moment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; + +class FeelingTest { + + @DisplayName("기뢄을 선택할 수 μžˆλ‹€.") + @Test + void match() { + assertThat(Feeling.match("happy")).isEqualTo(Feeling.HAPPY); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 기뢄을 선택할 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failMatch() { + assertThatThrownBy(() -> Feeling.match("upset")) + .isInstanceOf(StaccatoException.class) + .hasMessage("μš”μ²­ν•˜μ‹  κΈ°λΆ„ ν‘œν˜„μ„ 찾을 수 μ—†μ–΄μš”."); + } +} diff --git a/backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java b/backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java new file mode 100644 index 000000000..7e2a76ba2 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/domain/MomentImagesTest.java @@ -0,0 +1,56 @@ +package com.staccato.moment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.memory.domain.Memory; + +class MomentImagesTest { + @DisplayName("μƒμ„±ν•˜λ €λŠ” μ‚¬μ§„μ˜ κ°―μˆ˜κ°€ 5μž₯을 μ΄ˆκ³Όν•  μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failAddMomentImages() { + // given & when & then + assertThatThrownBy(() -> new MomentImages(List.of("picture1", "picture2", "picture3", "picture4", "picture5", "picture6"))) + .isInstanceOf(StaccatoException.class) + .hasMessage("사진은 5μž₯을 μ΄ˆκ³Όν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("사진을 μΆ”κ°€ν•  λ•Œ 총 μ‚¬μ§„μ˜ κ°―μˆ˜κ°€ 5μž₯을 μ΄ˆκ³Όν•  μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failUpdateMomentImages() { + // given & when & then + assertThatThrownBy(() -> new MomentImages(List.of("picture1", "picture2", "picture3", "picture4", "picture5", "picture6"))) + .isInstanceOf(StaccatoException.class) + .hasMessage("사진은 5μž₯을 μ΄ˆκ³Όν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("사진듀을 μΆ”κ°€ν•  λ•Œ κΈ°μ‘΄ 사진이 ν¬ν•¨λ˜μ§€ μ•Šμ€ 경우 μ‚­μ œ ν›„ μΆ”κ°€ν•œλ‹€.") + @Test + void update() { + // given + Memory memory = Memory.builder().title("Sample Memory").startAt(LocalDate.now().minusDays(1)) + .endAt(LocalDate.now().plusDays(1)).build(); + MomentImages existingImages = new MomentImages(List.of("picture1", "picture3")); + MomentImages updatedImages = new MomentImages(List.of("picture1", "picture4")); + + // when + existingImages.update(updatedImages, MomentFixture.create(memory, LocalDateTime.now())); + + // then + List images = existingImages.getImages().stream().map(MomentImage::getImageUrl).toList(); + assertAll( + () -> assertThat(images).containsAll(List.of("picture1", "picture4")), + () -> assertThat(images.size()).isEqualTo(2) + ); + } +} diff --git a/backend/src/test/java/com/staccato/moment/domain/MomentTest.java b/backend/src/test/java/com/staccato/moment/domain/MomentTest.java new file mode 100644 index 000000000..b0666b7bc --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/domain/MomentTest.java @@ -0,0 +1,111 @@ +package com.staccato.moment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.memory.domain.Memory; + +class MomentTest { + @DisplayName("μΆ”μ–΅ λ‚ μ§œ μ•ˆμ— μŠ€νƒ€μΉ΄ν†  λ‚ μ§œκ°€ ν¬ν•¨λ˜λ©΄ Moment을 생성할 수 μžˆλ‹€.") + @Test + void createMoment() { + // given + Memory memory = Memory.builder() + .title("Sample Memory") + .startAt(LocalDate.now()) + .endAt(LocalDate.now().plusDays(1)) + .build(); + + // when & then + assertThatCode(() -> Moment.builder() + .visitedAt(LocalDateTime.now().plusDays(1)) + .placeName("placeName") + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build()).doesNotThrowAnyException(); + } + + @DisplayName("μΆ”μ–΅ 기간이 μ—†λŠ” 경우 μŠ€νƒ€μΉ΄ν† λ₯Ό λ‚ μ§œ 상관없이 생성할 수 μžˆλ‹€.") + @Test + void createMomentInUndefinedDuration() { + // given + Memory memory = Memory.builder() + .title("Sample Memory") + .build(); + + // when & then + assertThatCode(() -> Moment.builder() + .visitedAt(LocalDateTime.now().plusDays(1)) + .placeName("placeName") + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build()).doesNotThrowAnyException(); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  생성 μ‹œ placeName의 μ•ž λ’€ 곡백이 μ œκ±°λœλ‹€.") + @Test + void trimPlaceName() { + // given + Memory memory = MemoryFixture.create(); + String placeName = " placeName "; + String expectedPlaceName = "placeName"; + + // when + Moment moment = Moment.builder() + .visitedAt(LocalDateTime.of(memory.getTerm().getStartAt(), LocalTime.MIN)) + .placeName(placeName) + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build(); + + // then + assertThat(moment.getPlaceName()).isEqualTo(expectedPlaceName); + } + + @DisplayName("μΆ”μ–΅ λ‚ μ§œ μ•ˆμ— μŠ€νƒ€μΉ΄ν†  λ‚ μ§œκ°€ ν¬ν•¨λ˜μ§€ μ•ŠμœΌλ©΄ μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚¨λ‹€.") + @ValueSource(longs = {-1, 2}) + @ParameterizedTest + void failCreateMoment(long plusDays) { + // given + Memory memory = Memory.builder() + .title("Sample Memory") + .startAt(LocalDate.now()) + .endAt(LocalDate.now().plusDays(1)) + .build(); + + // when & then + assertThatThrownBy(() -> Moment.builder() + .visitedAt(LocalDateTime.now().plusDays(plusDays)) + .placeName("placeName") + .latitude(BigDecimal.ONE) + .longitude(BigDecimal.ONE) + .address("address") + .memory(memory) + .momentImages(new MomentImages(List.of())) + .build()).isInstanceOf(StaccatoException.class) + .hasMessageContaining("좔얡에 ν¬ν•¨λ˜μ§€ μ•ŠλŠ” λ‚ μ§œμž…λ‹ˆλ‹€."); + } +} diff --git a/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java b/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java new file mode 100644 index 000000000..87ce9689a --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java @@ -0,0 +1,67 @@ +package com.staccato.moment.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.domain.MemoryMember; +import com.staccato.memory.repository.MemoryMemberRepository; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Moment; + +@DataJpaTest +class MomentRepositoryTest { + @Autowired + private MomentRepository momentRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MemoryMemberRepository memoryMemberRepository; + + @DisplayName("μ‚¬μš©μžμ˜ λͺ¨λ“  μŠ€νƒ€μΉ΄ν† λ₯Ό μ‘°νšŒν•œλ‹€.") + @Test + void findAllByMemory_MemoryMembers_Member() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Member anotherMember = memberRepository.save(MemberFixture.create("anotherMember")); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 31), LocalDate.of(2024, 1, 10))); + Memory memory2 = memoryRepository.save(MemoryFixture.create(LocalDate.of(2024, 7, 1), LocalDate.of(2024, 7, 10))); + Memory anotherMemberMemory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 10))); + memoryMemberRepository.save(new MemoryMember(member, memory)); + memoryMemberRepository.save(new MemoryMember(member, memory2)); + memoryMemberRepository.save(new MemoryMember(anotherMember, anotherMemberMemory)); + + Moment moment = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2023, 12, 31, 22, 20))); + Moment moment1 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 1, 22, 20))); + Moment moment2 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 1, 22, 21))); + Moment anotherMoment = momentRepository.save(MomentFixture.create(anotherMemberMemory, LocalDateTime.of(2024, 5, 1, 22, 21))); + + // when + List memberResult = momentRepository.findAllByMemory_MemoryMembers_Member(member); + List anotherMemberResult = momentRepository.findAllByMemory_MemoryMembers_Member(anotherMember); + + // then + assertAll( + () -> assertThat(memberResult.size()).isEqualTo(3), + () -> assertThat(memberResult).containsExactlyInAnyOrder(moment, moment1, moment2), + () -> assertThat(anotherMemberResult.size()).isEqualTo(1), + () -> assertThat(anotherMemberResult).containsExactlyInAnyOrder(anotherMoment) + ); + } +} diff --git a/backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java b/backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java new file mode 100644 index 000000000..595ca0b3c --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/service/MomentServiceTest.java @@ -0,0 +1,307 @@ +package com.staccato.moment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.staccato.ServiceSliceTest; +import com.staccato.comment.domain.Comment; +import com.staccato.comment.repository.CommentRepository; +import com.staccato.exception.ForbiddenException; +import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.moment.CommentFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.repository.MemberRepository; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.repository.MemoryRepository; +import com.staccato.moment.domain.Feeling; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; +import com.staccato.moment.repository.MomentImageRepository; +import com.staccato.moment.repository.MomentRepository; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.MomentUpdateRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +class MomentServiceTest extends ServiceSliceTest { + @Autowired + private MomentService momentService; + @Autowired + private MomentRepository momentRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired + private MomentImageRepository momentImageRepository; + @Autowired + private MemoryRepository memoryRepository; + @Autowired + private MemberRepository memberRepository; + + @DisplayName("사진 없이도 μŠ€νƒ€μΉ΄ν† λ₯Ό 생성할 수 μžˆλ‹€.") + @Test + void createMoment() { + // given + Member member = saveMember(); + saveMemory(member); + MomentRequest momentRequest = new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, List.of()); + + // when + long momentId = momentService.createMoment(momentRequest, member).momentId(); + + // then + assertThat(momentRepository.findById(momentId)).isNotEmpty(); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν† λ₯Ό μƒμ„±ν•˜λ©΄ Momentκ³Ό MomentImage듀이 ν•¨κ»˜ μ €μž₯되고 idλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void createMomentWithMomentImages() { + // given + Member member = saveMember(); + saveMemory(member); + + // when + long momentId = momentService.createMoment(getMomentRequest(), member).momentId(); + + // then + assertAll( + () -> assertThat(momentRepository.findById(momentId)).isNotEmpty(), + () -> assertThat(momentImageRepository.findFirstByMomentId(momentId)).isNotEmpty() + ); + } + + @DisplayName("본인 것이 μ•„λ‹Œ 좔얡에 μŠ€νƒ€μΉ΄ν† λ₯Ό μƒμ„±ν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotCreateMomentIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + saveMemory(member); + MomentRequest momentRequest = getMomentRequest(); + + // when & then + assertThatThrownBy(() -> momentService.createMoment(momentRequest, otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 좔얡에 μŠ€νƒ€μΉ΄ν†  생성을 μ‹œλ„ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failCreateMoment() { + // given + Member member = saveMember(); + + // when & then + assertThatThrownBy(() -> momentService.createMoment(getMomentRequest(), member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  좔얡을 찾을 수 μ—†μ–΄μš”."); + } + + private MomentRequest getMomentRequest() { + return new MomentRequest("placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, List.of("https://example.com/images/namsan_tower.jpg")); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  λͺ©λ‘ μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void readAllMoment() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + saveMomentWithImages(memory); + saveMomentWithImages(memory); + saveMomentWithImages(memory); + + // when + MomentLocationResponses actual = momentService.readAllMoment(member); + + // then + assertThat(actual).isEqualTo(new MomentLocationResponses( + List.of(new MomentLocationResponse(1L, new BigDecimal("37.7749").setScale(14, RoundingMode.HALF_UP), new BigDecimal("-122.4194").setScale(14, RoundingMode.HALF_UP)), + new MomentLocationResponse(2L, new BigDecimal("37.7749").setScale(14, RoundingMode.HALF_UP), new BigDecimal("-122.4194").setScale(14, RoundingMode.HALF_UP)), + new MomentLocationResponse(3L, new BigDecimal("37.7749").setScale(14, RoundingMode.HALF_UP), new BigDecimal("-122.4194").setScale(14, RoundingMode.HALF_UP))))); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void readMomentById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when + MomentDetailResponse actual = momentService.readMomentById(moment.getId(), member); + + // then + assertThat(actual).isEqualTo(new MomentDetailResponse(moment)); + } + + @DisplayName("본인 것이 μ•„λ‹Œ μŠ€νƒ€μΉ΄ν† λ₯Ό μ‘°νšŒν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotReadMomentByIdIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when & then + assertThatThrownBy(() -> momentService.readMomentById(moment.getId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μŠ€νƒ€μΉ΄ν† λ₯Ό μ‘°νšŒν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failReadMomentById() { + // given + Member member = saveMember(); + + // when & then + assertThatThrownBy(() -> momentService.readMomentById(1L, member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μŠ€νƒ€μΉ΄ν† λ₯Ό 찾을 수 μ—†μ–΄μš”."); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν†  μˆ˜μ •μ— μ„±κ³΅ν•œλ‹€.") + @Test + void updateMomentById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when + MomentUpdateRequest momentUpdateRequest = new MomentUpdateRequest("newPlaceName", List.of("https://existExample.com.jpg", "https://existExample2.com.jpg")); + momentService.updateMomentById(moment.getId(), momentUpdateRequest, member); + + // then + Moment foundedMoment = momentRepository.findById(moment.getId()).get(); + assertAll( + () -> assertThat(foundedMoment.getPlaceName()).isEqualTo("newPlaceName"), + () -> assertThat(momentImageRepository.findById(1L)).isEmpty(), + () -> assertThat(momentImageRepository.findById(2L)).isEmpty(), + () -> assertThat(momentImageRepository.findById(3L).get().getImageUrl()).isEqualTo("https://existExample.com.jpg"), + () -> assertThat(momentImageRepository.findById(4L).get().getImageUrl()).isEqualTo("https://existExample2.com.jpg"), + () -> assertThat(momentImageRepository.findById(3L).get().getMoment().getId()).isEqualTo(foundedMoment.getId()), + () -> assertThat(momentImageRepository.findById(4L).get().getMoment().getId()).isEqualTo(foundedMoment.getId()), + () -> assertThat(momentImageRepository.findAll().get(0).getImageUrl()).isEqualTo("https://existExample.com.jpg"), + () -> assertThat(momentImageRepository.findAll().get(1).getImageUrl()).isEqualTo("https://existExample2.com.jpg"), + () -> assertThat(momentImageRepository.findAll().size()).isEqualTo(2) + ); + } + + @DisplayName("본인 것이 μ•„λ‹Œ μŠ€νƒ€μΉ΄ν† λ₯Ό μˆ˜μ •ν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotUpdateMomentByIdIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + MomentUpdateRequest momentUpdateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + + // when & then + assertThatThrownBy(() -> momentService.updateMomentById(moment.getId(), momentUpdateRequest, otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μŠ€νƒ€μΉ΄ν† λ₯Ό μˆ˜μ •ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void failUpdateMomentById() { + // given + Member member = saveMember(); + MomentUpdateRequest momentUpdateRequest = new MomentUpdateRequest("placeName", List.of("https://example1.com.jpg")); + + // when & then + assertThatThrownBy(() -> momentService.updateMomentById(1L, momentUpdateRequest, member)) + .isInstanceOf(StaccatoException.class) + .hasMessageContaining("μš”μ²­ν•˜μ‹  μŠ€νƒ€μΉ΄ν† λ₯Ό 찾을 수 μ—†μ–΄μš”."); + } + + @DisplayName("Moment을 μ‚­μ œν•˜λ©΄ 이에 ν¬ν•¨λœ MomentImage와 MomentLog도 λͺ¨λ‘ μ‚­μ œλœλ‹€.") + @Test + void deleteMomentById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + Comment comment = commentRepository.save(CommentFixture.create(moment, member)); + + // when + momentService.deleteMomentById(moment.getId(), member); + + // then + assertAll( + () -> assertThat(momentRepository.findById(moment.getId())).isEmpty(), + () -> assertThat(commentRepository.findById(comment.getId())).isEmpty(), + () -> assertThat(momentImageRepository.findById(0L)).isEmpty(), + () -> assertThat(momentImageRepository.findById(1L)).isEmpty() + ); + } + + @DisplayName("본인 것이 μ•„λ‹Œ μŠ€νƒ€μΉ΄ν† λ₯Ό μ‚­μ œν•˜λ €κ³  ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void cannotDeleteMomentByIdIfNotOwner() { + // given + Member member = saveMember(); + Member otherMember = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + + // when & then + assertThatThrownBy(() -> momentService.deleteMomentById(moment.getId(), otherMember)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("μš”μ²­ν•˜μ‹  μž‘μ—…μ„ μ²˜λ¦¬ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("μŠ€νƒ€μΉ΄ν† μ˜ 기뢄을 선택할 수 μžˆλ‹€.") + @Test + void updateMomentFeelingById() { + // given + Member member = saveMember(); + Memory memory = saveMemory(member); + Moment moment = saveMomentWithImages(memory); + FeelingRequest feelingRequest = new FeelingRequest("happy"); + + // when + momentService.updateMomentFeelingById(moment.getId(), member, feelingRequest); + + // then + assertAll( + () -> assertThat(momentRepository.findById(moment.getId())).isNotEmpty(), + () -> assertThat(momentRepository.findById(moment.getId()).get().getFeeling()).isEqualTo(Feeling.HAPPY) + ); + } + + private Member saveMember() { + return memberRepository.save(MemberFixture.create()); + } + + private Memory saveMemory(Member member) { + Memory memory = MemoryFixture.create(LocalDate.now(), LocalDate.now().plusDays(1)); + memory.addMemoryMember(member); + return memoryRepository.save(memory); + } + + private Moment saveMomentWithImages(Memory memory) { + Moment moment = MomentFixture.createWithImages(memory, LocalDateTime.now(), new MomentImages(List.of("https://oldExample.com.jpg", "https://existExample.com.jpg"))); + return momentRepository.save(moment); + } +} diff --git a/backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java b/backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java new file mode 100644 index 000000000..186a31228 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/service/dto/request/MomentRequestTest.java @@ -0,0 +1,34 @@ +package com.staccato.moment.service.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MomentRequestTest { + @DisplayName("MomentRequestλ₯Ό 생성할 λ•Œ placeNameμ—λŠ” trim이 μ μš©λœλ‹€.") + @Test + void trimPlaceName() { + // given + String placeName = " placeName "; + String expectedPlaceName = "placeName"; + + // when + MomentRequest momentRequest = new MomentRequest( + placeName, + "address", + BigDecimal.ONE, + BigDecimal.ONE, + LocalDateTime.of(2024, 8, 22, 10, 0), + 1L, + List.of() + ); + + // then + assertThat(momentRequest.placeName()).isEqualTo(expectedPlaceName); + } +} diff --git a/backend/src/test/java/com/staccato/util/DatabaseCleaner.java b/backend/src/test/java/com/staccato/util/DatabaseCleaner.java new file mode 100644 index 000000000..6247cc7c4 --- /dev/null +++ b/backend/src/test/java/com/staccato/util/DatabaseCleaner.java @@ -0,0 +1,49 @@ +package com.staccato.util; + +import java.util.List; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class DatabaseCleaner { + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void cleanUp() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : getTableNames()) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1") + .executeUpdate(); + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } + + private List getTableNames() { + return entityManager.getMetamodel().getEntities().stream() + .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null) + .map(entityType -> camelToSnake(entityType.getName())) + .toList(); + } + + private String camelToSnake(String camel) { + StringBuilder snake = new StringBuilder(); + for (char c : camel.toCharArray()) { + if (Character.isUpperCase(c)) { + snake.append("_"); + } + snake.append(Character.toLowerCase(c)); + } + if (snake.charAt(0) == '_') { + snake.deleteCharAt(0); + } + return snake.toString(); + } +} diff --git a/backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java b/backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java new file mode 100644 index 000000000..b3d1aa1ad --- /dev/null +++ b/backend/src/test/java/com/staccato/util/DatabaseCleanerExtension.java @@ -0,0 +1,13 @@ +package com.staccato.util; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseCleanerExtension implements BeforeEachCallback { + @Override + public void beforeEach(ExtensionContext context) { + DatabaseCleaner databaseCleaner = SpringExtension.getApplicationContext(context).getBean(DatabaseCleaner.class); + databaseCleaner.cleanUp(); + } +}