diff --git a/.github/workflows/backend-dev.yml b/.github/workflows/backend-dev.yml index a3257f028..d41e8468f 100644 --- a/.github/workflows/backend-dev.yml +++ b/.github/workflows/backend-dev.yml @@ -70,3 +70,41 @@ jobs: - name: Docker run run: sudo docker run -d -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} + + deploy-prod-1: + needs: build + runs-on: [ self-hosted, backend-prod-1 ] + steps: + - name: Docker remove + run: | + CONTAINER_IDS=$(sudo docker ps -qa) + if [ -n "$CONTAINER_IDS" ]; then + sudo docker rm -f $CONTAINER_IDS + else + echo "No running containers found." + fi + + - name: Docker Image pull + run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} + + - name: Docker run + run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} + + deploy-prod-2: + needs: build + runs-on: [ self-hosted, backend-prod-2 ] + steps: + - name: Docker remove + run: | + CONTAINER_IDS=$(sudo docker ps -qa) + if [ -n "$CONTAINER_IDS" ]; then + sudo docker rm -f $CONTAINER_IDS + else + echo "No running containers found." + fi + + - name: Docker Image pull + run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} + + - name: Docker run + run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} diff --git a/.github/workflows/backend-prod.yml b/.github/workflows/backend-prod.yml index f7daacd33..0c7a5073b 100644 --- a/.github/workflows/backend-prod.yml +++ b/.github/workflows/backend-prod.yml @@ -54,9 +54,9 @@ jobs: docker buildx build --platform linux/arm64 -t \ ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }} --push . - deploy: + deploy-prod-1: needs: build - runs-on: [ self-hosted, backend-prod ] + runs-on: [ self-hosted, backend-prod-1 ] steps: - name: Docker remove run: | @@ -71,4 +71,25 @@ jobs: run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }} - name: Docker run - run: sudo docker run -d -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }} + run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} + + deploy-prod-2: + needs: build + runs-on: [ self-hosted, backend-prod-2 ] + steps: + - name: Docker remove + run: | + CONTAINER_IDS=$(sudo docker ps -qa) + if [ -n "$CONTAINER_IDS" ]; then + sudo docker rm -f $CONTAINER_IDS + else + echo "No running containers found." + fi + + - name: Docker Image pull + run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }} + + - name: Docker run + run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }} + + diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 000000000..671ee930b --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,244 @@ +# Created by https://www.toptal.com/developers/gitignore/api/java,intellij,gradle,macos,windows,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=java,intellij,gradle,macos,windows,linux + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle,macos,windows,linux diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 000000000..df2cf44e0 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:17-jdk-slim + +WORKDIR /app + +COPY /build/libs/*.jar /app/haengdong-0.0.1-SNAPSHOT.jar + +EXPOSE 8080 +ENTRYPOINT ["java"] +CMD ["-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-Duser.timezone=Asia/Seoul", "-jar", "haengdong-0.0.1-SNAPSHOT.jar"] diff --git a/server/build.gradle b/server/build.gradle new file mode 100644 index 000000000..c2936590a --- /dev/null +++ b/server/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} + +group = 'server' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + useJUnitPlatform() + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + baseDirFollowsSourceFile() + dependsOn test +} + +tasks.resolveMainClassName { + dependsOn 'copyApiDocuments' +} + +tasks.register('copyApiDocuments', Copy) { + dependsOn asciidoctor + from file("build/docs/asciidoc") + into file("build/resources/main/static/docs") +} + +bootJar { + dependsOn copyApiDocuments +} + +jar { + enabled = false +} + +build { + dependsOn copyApiDocuments +} diff --git a/server/docs/24-08-04-erd.sql b/server/docs/24-08-04-erd.sql new file mode 100644 index 000000000..ae2fbc4a5 --- /dev/null +++ b/server/docs/24-08-04-erd.sql @@ -0,0 +1,65 @@ +-- Create tables +CREATE TABLE action +( + event_id BIGINT, + id BIGINT AUTO_INCREMENT, + sequence BIGINT, + PRIMARY KEY (id) +); + +CREATE TABLE bill_action +( + action_id BIGINT UNIQUE, + id BIGINT AUTO_INCREMENT, + price BIGINT, + title VARCHAR(30), + PRIMARY KEY (id) +); + +CREATE TABLE event +( + id BIGINT AUTO_INCREMENT, + name VARCHAR(255), + token VARCHAR(255), + PRIMARY KEY (id) +); + +CREATE TABLE event_step +( + event_id BIGINT, + id BIGINT AUTO_INCREMENT, + sequence BIGINT, + name VARCHAR(255), + PRIMARY KEY (id) +); + +CREATE TABLE member_action +( + action_id BIGINT UNIQUE, + id BIGINT AUTO_INCREMENT, + member_group_id BIGINT, + member_name VARCHAR(255), + status ENUM('IN', 'OUT'), + PRIMARY KEY (id) +); + +-- Add foreign key constraints +ALTER TABLE action + ADD CONSTRAINT FKgf0qmub9va1xbe44nehny31yw + FOREIGN KEY (event_id) + REFERENCES event (id); + +ALTER TABLE bill_action + ADD CONSTRAINT FK54tx517tp0ry6453olkply4us + FOREIGN KEY (action_id) + REFERENCES action (id); + +ALTER TABLE event_step + ADD CONSTRAINT FKe3rkib91cvl0x5w9wqkshmn81 + FOREIGN KEY (event_id) + REFERENCES event (id); + +ALTER TABLE member_action + ADD CONSTRAINT FK5jna51dn8fs2ir52l4uwn517u + FOREIGN KEY (action_id) + REFERENCES action (id); diff --git a/server/docs/24-08-04-erd.svg b/server/docs/24-08-04-erd.svg new file mode 100644 index 000000000..5a4bac225 --- /dev/null +++ b/server/docs/24-08-04-erd.svg @@ -0,0 +1,4 @@ + + + +
Event
Event
PK
PK
event_id
event_id
bigint
bigint
token
token
varchar(20)
varchar(20)
name
name
varchar(255)
varchar(255)
Event_Step
Event_Step
PK
PK
event_step_id
event_step_id
bigint
bigint
name
name
varchar(30)
varchar(30)
sequence
sequence
bigint
bigint
FK
FK
event_id
event_id
bigint
bigint
Action
Action
PK
PK
action_id
action_id
bigint
bigint
sequence
sequence
bigint
bigint
FK
FK
event_id
event_id
bigint
bigint
Bill_Action
Bill_Action
PK
PK
bill_action_id
bill_action_id
bigint
bigint
title
title
varchar(30)
varchar(30)
price
price
bigint
bigint
FK
FK
action_id
action_id
bigint
bigint
Member_Action
Member_Action
PK
PK
member_action_id
member_action_id
bigint
bigint
member_name
member_name
varchar(20)
varchar(20)
status
status
varchar(10)
varchar(10)
member_group_id
member_group_id
bigint
bigint
FK
FK
action_id
action_id
bigint
bigint
Text is not SVG - cannot display
\ No newline at end of file diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e6441136f Binary files /dev/null and b/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a4413138c --- /dev/null +++ b/server/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/server/gradlew b/server/gradlew new file mode 100644 index 000000000..b740cf133 --- /dev/null +++ b/server/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/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/server/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/server/settings.gradle b/server/settings.gradle new file mode 100644 index 000000000..dbbee46fb --- /dev/null +++ b/server/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'haengdong' diff --git a/server/src/docs/asciidoc/bill.adoc b/server/src/docs/asciidoc/bill.adoc new file mode 100644 index 000000000..64b366594 --- /dev/null +++ b/server/src/docs/asciidoc/bill.adoc @@ -0,0 +1,121 @@ +== 지출 + +=== 지출 생성 + +operation::createBills[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"REQUEST_EMPTY", + "message":"지출 금액은 비어 있으면 안됩니다." + }, + { + "code":"REQUEST_EMPTY", + "message":"지출 내역은 비어 있으면 안됩니다." + }, + { + "code":"BILL_TITLE_INVALID", + "message":"앞뒤 공백을 제거한 지출 내역 제목은 %d ~ %d자여야 합니다." + }, + { + "code":"BILL_PRICE_INVALID", + "message":"지출 금액은 10,000,000 이하의 자연수여야 합니다." + }, + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + } +] +---- + +=== 지출 수정 + +operation::updateBill[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"REQUEST_EMPTY", + "message":"지출 내역 제목은 공백일 수 없습니다." + }, + { + "code":"REQUEST_EMPTY", + "message":"지출 금액은 공백일 수 없습니다." + }, + { + "code":"BILL_TITLE_INVALID", + "message":"앞뒤 공백을 제거한 지출 내역 제목은 %d ~ %d자여야 합니다." + }, + { + "code":"BILL_PRICE_INVALID", + "message":"지출 금액은 %,d 이하의 자연수여야 합니다." + }, + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"BILL_NOT_FOUND", + "message":"존재하지 않는 지출입니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + } +] +---- + +=== 지출 삭제 + +operation::deleteBill[snippets="path-parameters,http-request,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + } +] +---- diff --git a/server/src/docs/asciidoc/billDetail.adoc b/server/src/docs/asciidoc/billDetail.adoc new file mode 100644 index 000000000..349fb309c --- /dev/null +++ b/server/src/docs/asciidoc/billDetail.adoc @@ -0,0 +1,93 @@ +== 지출 상세 + +=== 지출 상세 조회 + +operation::findBillDetails[snippets="path-parameters,http-request,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code": "EVENT_NOT_FOUND", + "message": "존재하지 않는 행사입니다." + }, + { + "code": "BILL_NOT_FOUND", + "message": "존재하지 않는 지출입니다." + }, + { + "code": "BILL_DETAIL_NOT_FOUND", + "message": "존재하지 않는 참여자 지출입니다." + }, + { + "code": "TOKEN_NOT_FOUND", + "message": "토큰이 존재하지 않습니다." + }, + { + "code": "TOKEN_EXPIRED", + "message": "만료된 토큰입니다." + }, + { + "code": "TOKEN_INVALID", + "message": "유효하지 않은 토큰입니다." + }, + { + "code": "FORBIDDEN", + "message": "접근할 수 없는 행사입니다." + } +] +---- + +=== 지출 상세 수정 + +operation::updateBillDetails[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code": "REQUEST_EMPTY", + "message": "멤버 이름은 공백일 수 없습니다." + }, + { + "code": "REQUEST_EMPTY", + "message": "지출 금액은 공백일 수 없습니다." + }, + { + "code": "EVENT_NOT_FOUND", + "message": "존재하지 않는 행사입니다." + }, + { + "code": "BILL_NOT_FOUND", + "message": "존재하지 않는 지출 입니다." + }, + { + "code": "BILL_DETAIL_NOT_FOUND", + "message": "존재하지 않는 참여자 지출입니다." + }, + { + "code": "BILL_PRICE_NOT_MATCHED", + "message": "지출 총액이 일치하지 않습니다." + }, + { + "code": "TOKEN_NOT_FOUND", + "message": "토큰이 존재하지 않습니다." + }, + { + "code": "TOKEN_EXPIRED", + "message": "만료된 토큰입니다." + }, + { + "code": "TOKEN_INVALID", + "message": "유효하지 않은 토큰입니다." + }, + { + "code": "FORBIDDEN", + "message": "접근할 수 없는 행사입니다." + } +] +---- diff --git a/server/src/docs/asciidoc/event.adoc b/server/src/docs/asciidoc/event.adoc new file mode 100644 index 000000000..10b165282 --- /dev/null +++ b/server/src/docs/asciidoc/event.adoc @@ -0,0 +1,205 @@ +== 행사 + +=== 행사 생성 + +operation::createEvent[snippets="http-request,request-body,request-fields,response-body,response-fields,http-response,response-cookies"] +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"REQUEST_EMPTY", + "message":"행사 이름은 공백일 수 없습니다." + }, + { + "code":"EVENT_NAME_LENGTH_INVALID", + "message":"행사 이름은 2자 이상 30자 이하만 입력 가능합니다. 입력한 이름 길이 : 21" + }, + { + "code":"EVENT_NAME_MULTIPLE_BLANK", + "message":"행사 이름에는 공백 문자가 연속될 수 없습니다. 입력한 이름 : 공백 문자" + }, + { + "code":"EVENT_PASSWORD_INVALID", + "message":"비밀번호는 4자리 숫자만 가능합니다." + } +] +---- + +=== 행사 관리자 로그인 + +operation::eventLogin[snippets="path-parameters,http-request,request-body,request-fields,http-response,response-cookies"] +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"REQUEST_EMPTY", + "message":"비밀번호는 공백일 수 없습니다." + }, + { + "code":"PASSWORD_INVALID", + "message":"비밀번호가 일치하지 않습니다." + } +] +---- + +=== 행사 정보 조회 + +operation::getEvent[snippets="path-parameters,http-request,response-body,response-fields,http-response"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + } +] +---- + +=== 행사 전체 참여자 목록 조회 + +operation::findAllMembers[snippets="path-parameters,http-request,response-body,response-fields,http-response,response-fields"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + } +] +---- + +=== 행사 전체 지출 이력 조회 + +operation::findBills[snippets="path-parameters,http-request,http-response,response-fields"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + } +] +---- + +=== 행사 참여자 정보 변경 + +operation::updateMembers[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"] +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + + { + "code":"MEMBER_NAME_CHANGE_DUPLICATE", + "message":"중복된 참여 인원 이름 변경 요청이 존재합니다." + }, + { + "code":"MEMBER_NOT_EXIST", + "message":"현재 참여하고 있지 않는 인원이 존재합니다." + }, + { + "code":"REQUEST_EMPTY", + "message":"멤버 이름은 공백일 수 없습니다." + }, + { + "code":"MEMBER_NAME_LENGTH_INVALID", + "message":"멤버 이름은 1자 이상 4자 이하만 입력 가능합니다." + }, + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"MEMBER_NAME_DUPLICATE", + "message":"중복된 행사 참여 인원 이름이 존재합니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"만료된 토큰입니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + } +] +---- + +=== 행사 참여자 삭제 + +operation::deleteMember[snippets="path-parameters,http-request,http-response,request-cookies"] +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + } +] +---- + +=== 행사 어드민 권한 확인 + +operation::authenticateEvent[snippets="http-request,http-response,request-cookies"] +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code": "EVENT_NOT_FOUND", + "message": "존재하지 않는 행사입니다." + }, + { + "code": "TOKEN_NOT_FOUND", + "message": "토큰이 존재하지 않습니다." + }, + { + "code": "TOKEN_EXPIRED", + "message": "만료된 토큰입니다." + }, + { + "code": "TOKEN_INVALID", + "message": "유효하지 않은 토큰입니다." + }, + { + "code": "FORBIDDEN", + "message": "접근할 수 없는 행사입니다." + } +] +---- diff --git a/server/src/docs/asciidoc/index.adoc b/server/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..4dcc2dc26 --- /dev/null +++ b/server/src/docs/asciidoc/index.adoc @@ -0,0 +1,15 @@ +ifndef::snippets[] +:snippets: ../../build/generated-snippets +endif::[] += 행동대장 +:source-highlighter: highlightjs :hardbreaks: +:toc: left :doctype: book :icons: font :toc-title: 전체 API 목록 :toclevels: 2 :sectlinks: +:sectnums: +:sectnumlevels: 2 + + +include::{docdir}/event.adoc[] +include::{docdir}/memberBillReport.adoc[] +include::{docdir}/member.adoc[] +include::{docdir}/bill.adoc[] +include::{docdir}/billDetail.adoc[] diff --git a/server/src/docs/asciidoc/member.adoc b/server/src/docs/asciidoc/member.adoc new file mode 100644 index 000000000..e37a57d57 --- /dev/null +++ b/server/src/docs/asciidoc/member.adoc @@ -0,0 +1,152 @@ +== 참여자 + +=== 행사 참여자 추가(멤버 추가) + +operation::saveMembers[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"REQUEST_EMPTY", + "message":"멤버 목록은 공백일 수 없습니다." + }, + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"MEMBER_ALREADY_EXIST", + "message":"현재 참여하고 있는 인원이 존재합니다." + }, + { + "code":"MEMBER_NAME_DUPLICATE", + "message":"중복된 이름이 존재합니다. 입력된 이름: [이상, 이상, 감자, 백호]" + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"만료된 토큰입니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + }, + { + "code":"FORBIDDEN", + "message":"접근할 수 없는 행사입니다." + } +] +---- + +=== 행사 참여 인원에서 삭제(멤버 삭제) + +operation::deleteMember[snippets="path-parameters,http-request,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"만료된 토큰입니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + }, + { + "code":"FORBIDDEN", + "message":"접근할 수 없는 행사입니다." + } +] +---- + +=== 멤버 정보 수정 + +operation::updateMembers[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code": "REQUEST_EMPTY", + "message": "멤버 ID는 공백일 수 없습니다." + }, + { + "code": "REQUEST_EMPTY", + "message": "입금 여부는 공백일 수 없습니다." + }, + { + "code": "REQUEST_EMPTY", + "message": "멤버 이름은 공백일 수 없습니다." + }, + { + "code": "MEMBER_NAME_LENGTH_INVALID", + "message": "멤버 이름은 1자 이상 4자 이하만 입력 가능합니다." + }, + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + }, + { + "code": "MEMBER_UPDATE_MISMATCH", + "message": "업데이트 요청된 참여자 정보와 기존 행사 참여자 정보가 일치하지 않습니다." + }, + { + "code": "MEMBER_NAME_DUPLICATE", + "message": "중복된 행사 참여 인원 이름이 존재합니다." + }, + { + "code": "MEMBER_NAME_CHANGE_DUPLICATE", + "message": "중복된 참여 인원 이름 변경 요청이 존재합니다." + }, + { + "code":"TOKEN_NOT_FOUND", + "message":"토큰이 존재하지 않습니다." + }, + { + "code":"TOKEN_EXPIRED", + "message":"만료된 토큰입니다." + }, + { + "code":"TOKEN_INVALID", + "message":"유효하지 않은 토큰입니다." + }, + { + "code":"FORBIDDEN", + "message":"접근할 수 없는 행사입니다." + } +] +---- + +=== 현재 행사에 참여 중인 (탈주 가능한) 참여자 목록 조회 + +operation::getCurrentMembers[snippets="path-parameters,http-request,http-response,response-fields"] +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + } +] +---- diff --git a/server/src/docs/asciidoc/memberBillReport.adoc b/server/src/docs/asciidoc/memberBillReport.adoc new file mode 100644 index 000000000..18bd7d3ef --- /dev/null +++ b/server/src/docs/asciidoc/memberBillReport.adoc @@ -0,0 +1,17 @@ +== 정산 + +=== 참여자별 정산 결과 조회 + +operation::getMemberBillReports[snippets="path-parameters,http-request,response-body,response-fields,http-response,http-request"] + +==== [.red]#Exceptions# + +[source,json,options="nowrap"] +---- +[ + { + "code":"EVENT_NOT_FOUND", + "message":"존재하지 않는 행사입니다." + } +] +---- diff --git a/server/src/main/java/server/haengdong/HaengdongApplication.java b/server/src/main/java/server/haengdong/HaengdongApplication.java new file mode 100644 index 000000000..4a84c6120 --- /dev/null +++ b/server/src/main/java/server/haengdong/HaengdongApplication.java @@ -0,0 +1,15 @@ +package server.haengdong; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@Slf4j +@SpringBootApplication +public class HaengdongApplication { + + public static void main(String[] args) { + SpringApplication.run(HaengdongApplication.class, args); + } + +} diff --git a/server/src/main/java/server/haengdong/application/AuthService.java b/server/src/main/java/server/haengdong/application/AuthService.java new file mode 100644 index 000000000..d9352ad53 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/AuthService.java @@ -0,0 +1,41 @@ +package server.haengdong.application; + + +import java.util.Map; +import server.haengdong.domain.TokenProvider; +import server.haengdong.exception.AuthenticationException; +import server.haengdong.exception.HaengdongErrorCode; + +public class AuthService { + + private static final String TOKEN_NAME = "eventToken"; + private static final String CLAIM_SUB = "sub"; + + private final TokenProvider tokenProvider; + + public AuthService(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + public String createToken(String eventId) { + Map payload = Map.of(CLAIM_SUB, eventId); + + return tokenProvider.createToken(payload); + } + + public String findEventIdByToken(String token) { + validateToken(token); + Map payload = tokenProvider.getPayload(token); + return (String) payload.get(CLAIM_SUB); + } + + private void validateToken(String token) { + if (!tokenProvider.validateToken(token)) { + throw new AuthenticationException(HaengdongErrorCode.TOKEN_INVALID); + } + } + + public String getTokenName() { + return TOKEN_NAME; + } +} diff --git a/server/src/main/java/server/haengdong/application/BillService.java b/server/src/main/java/server/haengdong/application/BillService.java new file mode 100644 index 000000000..407914d29 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/BillService.java @@ -0,0 +1,155 @@ +package server.haengdong.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import server.haengdong.application.request.BillAppRequest; +import server.haengdong.application.request.BillDetailUpdateAppRequest; +import server.haengdong.application.request.BillDetailsUpdateAppRequest; +import server.haengdong.application.request.BillUpdateAppRequest; +import server.haengdong.application.response.BillDetailsAppResponse; +import server.haengdong.application.response.StepAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.bill.BillDetail; +import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventRepository; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.member.MemberRepository; +import server.haengdong.domain.step.Steps; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class BillService { + + private final BillRepository billRepository; + private final EventRepository eventRepository; + private final MemberRepository memberRepository; + + @Transactional + public void saveBill(String eventToken, BillAppRequest request) { + Event event = getEvent(eventToken); + List memberIds = request.memberIds(); + List members = memberIds.stream() + .map(this::findMember) + .toList(); + + Bill bill = request.toBill(event, members); + billRepository.save(bill); + } + + private Member findMember(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.MEMBER_NOT_FOUND)); + } + + public List findSteps(String token) { + Event event = getEvent(token); + List bills = billRepository.findAllByEvent(event); + + return createStepAppResponses(bills); + } + + private List createStepAppResponses(List bills) { + Steps steps = Steps.of(bills); + return steps.getSteps().stream() + .map(StepAppResponse::of) + .toList(); + } + + @Transactional + public void updateBill(String token, Long billId, BillUpdateAppRequest request) { + Bill bill = getBill(billId); + + validateToken(token, bill); + + bill.update(request.title(), request.price()); + } + + private void validateToken(String token, Bill bill) { + Event event = bill.getEvent(); + if (event.isTokenMismatch(token)) { + throw new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND); + } + } + + @Transactional + public void deleteBill(String token, Long billId) { + Bill bill = getBill(billId); + validateToken(token, bill); + billRepository.deleteById(billId); + } + + public BillDetailsAppResponse findBillDetails(String token, Long billId) { + Bill bill = billRepository.findById(billId) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND)); + validateToken(token, bill); + + List billDetails = bill.getBillDetails(); + return BillDetailsAppResponse.of(billDetails); + } + + @Transactional + public void updateBillDetails(String token, Long billId, BillDetailsUpdateAppRequest request) { + Bill bill = billRepository.findById(billId) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND)); + + List billDetailUpdateAppRequests = request.billDetailUpdateAppRequests(); + + validateToken(token, bill); + validateBillDetailSize(billDetailUpdateAppRequests, bill); + validateTotalPrice(billDetailUpdateAppRequests, bill); + + List billDetails = bill.getBillDetails(); + + for (BillDetailUpdateAppRequest updateRequest : billDetailUpdateAppRequests) { + BillDetail detailToUpdate = billDetails.stream() + .filter(detail -> detail.isSameId(updateRequest.id())) + .findFirst() + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_DETAIL_NOT_FOUND)); + + detailToUpdate.updatePrice(updateRequest.price()); + detailToUpdate.updateIsFixed(updateRequest.isFixed()); + } + } + + private void validateBillDetailSize(List requests, Bill bill) { + List ids = requests.stream() + .map(BillDetailUpdateAppRequest::id) + .distinct() + .toList(); + if (bill.getBillDetails().size() != ids.size()) { + throw new HaengdongException(HaengdongErrorCode.BILL_DETAIL_NOT_FOUND); + } + } + + private void validateTotalPrice( + List billDetailUpdateAppRequests, + Bill bill + ) { + Long requestsPriceSum = calculateUpdatePriceSum(billDetailUpdateAppRequests); + if (!bill.isSamePrice(requestsPriceSum)) { + throw new HaengdongException(HaengdongErrorCode.BILL_PRICE_NOT_MATCHED); + } + } + + private Long calculateUpdatePriceSum(List billDetailUpdateAppRequests) { + return billDetailUpdateAppRequests.stream() + .map(BillDetailUpdateAppRequest::price) + .reduce(0L, Long::sum); + } + + private Event getEvent(String eventToken) { + return eventRepository.findByToken(eventToken) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND)); + } + + private Bill getBill(Long billId) { + return billRepository.findById(billId) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND)); + } +} diff --git a/server/src/main/java/server/haengdong/application/EventService.java b/server/src/main/java/server/haengdong/application/EventService.java new file mode 100644 index 000000000..af49ebb5a --- /dev/null +++ b/server/src/main/java/server/haengdong/application/EventService.java @@ -0,0 +1,95 @@ +package server.haengdong.application; + +import java.util.List; +import java.util.Map.Entry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import server.haengdong.application.request.EventAppRequest; +import server.haengdong.application.request.EventLoginAppRequest; +import server.haengdong.application.request.EventUpdateAppRequest; +import server.haengdong.application.response.EventAppResponse; +import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.MemberBillReportAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.bill.MemberBillReport; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventRepository; +import server.haengdong.domain.event.EventTokenProvider; +import server.haengdong.exception.AuthenticationException; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class EventService { + + private final EventRepository eventRepository; + private final EventTokenProvider eventTokenProvider; + private final BillRepository billRepository; + + @Transactional + public EventAppResponse saveEvent(EventAppRequest request) { + String token = eventTokenProvider.createToken(); + Event event = request.toEvent(token); + eventRepository.save(event); + + return EventAppResponse.of(event); + } + + public EventDetailAppResponse findEvent(String token) { + Event event = getEvent(token); + + return EventDetailAppResponse.of(event); + } + + public void validatePassword(EventLoginAppRequest request) throws HaengdongException { + Event event = getEvent(request.token()); + if (event.isPasswordMismatch(request.password())) { + throw new AuthenticationException(HaengdongErrorCode.PASSWORD_INVALID); + } + } + + private Event getEvent(String token) { + return eventRepository.findByToken(token) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND)); + } + + public List getMemberBillReports(String token) { + Event event = eventRepository.findByToken(token) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND)); + List bills = billRepository.findAllByEvent(event); + + MemberBillReport memberBillReport = MemberBillReport.createByBills(bills); + + return memberBillReport.getReports().entrySet().stream() + .map(this::createMemberBillReportResponse) + .toList(); + } + + private MemberBillReportAppResponse createMemberBillReportResponse(Entry entry) { + Member member = entry.getKey(); + Long price = entry.getValue(); + + return new MemberBillReportAppResponse( + member.getId(), + member.getName(), + member.isDeposited(), + price + ); + } + + @Transactional + public void updateEvent(String token, EventUpdateAppRequest request) { + Event event = getEvent(token); + if (request.isEventNameExist()) { + event.rename(request.eventName()); + } + if (request.isAccountExist()) { + event.changeAccount(request.bankName(), request.accountNumber()); + } + } +} diff --git a/server/src/main/java/server/haengdong/application/MemberService.java b/server/src/main/java/server/haengdong/application/MemberService.java new file mode 100644 index 000000000..840c4f769 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/MemberService.java @@ -0,0 +1,118 @@ +package server.haengdong.application; + + +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import server.haengdong.application.request.MemberSaveAppRequest; +import server.haengdong.application.request.MembersSaveAppRequest; +import server.haengdong.application.request.MembersUpdateAppRequest; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.MembersDepositAppResponse; +import server.haengdong.application.response.MembersSaveAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventRepository; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.member.MemberRepository; +import server.haengdong.domain.member.UpdatedMembers; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class MemberService { + + private final MemberRepository memberRepository; + private final EventRepository eventRepository; + private final BillRepository billRepository; + + @Transactional + public MembersSaveAppResponse saveMembers(String token, MembersSaveAppRequest request) { + Event event = getEvent(token); + List memberNames = request.members().stream() + .map(MemberSaveAppRequest::name) + .toList(); + + validateMemberSave(memberNames, event); + + List members = memberNames.stream() + .map(name -> new Member(event, name)) + .toList(); + + List savedMembers = memberRepository.saveAll(members); + return MembersSaveAppResponse.of(savedMembers); + } + + private void validateMemberSave(List memberNames, Event event) { + Set uniqueMemberNames = Set.copyOf(memberNames); + if (memberNames.size() != uniqueMemberNames.size()) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_DUPLICATE, memberNames); + } + if (isDuplicatedMemberNames(uniqueMemberNames, event)) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_ALREADY_EXIST); + } + } + + private boolean isDuplicatedMemberNames(Set uniqueMemberNames, Event event) { + return memberRepository.findAllByEvent(event).stream() + .anyMatch(member -> uniqueMemberNames.contains(member.getName())); + } + + public List getCurrentMembers(String token) { + Event event = getEvent(token); + + return billRepository.findFirstByEventOrderByIdDesc(event) + .map(Bill::getMembers) + .orElseGet(() -> memberRepository.findAllByEvent(event)) + .stream() + .map(MemberAppResponse::of) + .toList(); + } + + public MembersDepositAppResponse findAllMembers(String token) { + Event event = getEvent(token); + + List members = memberRepository.findAllByEvent(event); + + return MembersDepositAppResponse.of(members); + } + + @Transactional + public void updateMembers(String token, MembersUpdateAppRequest request) { + Event event = getEvent(token); + UpdatedMembers updatedMembers = new UpdatedMembers(request.toMembers(event)); + List originMembers = memberRepository.findAllByEvent(event); + + updatedMembers.validateUpdatable(originMembers); + memberRepository.saveAll(updatedMembers.getMembers()); + } + + @Transactional + public void deleteMember(String token, Long memberId) { + memberRepository.findById(memberId) + .ifPresent(member -> deleteMember(token, member)); + } + + private void deleteMember(String token, Member member) { + Event event = member.getEvent(); + if (event.isTokenMismatch(token)) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_NOT_FOUND); + } + + billRepository.findAllByEvent(event).stream() + .filter(bill -> bill.containMember(member)) + .forEach(bill -> bill.removeMemberBillDetail(member)); + billRepository.flush(); + memberRepository.delete(member); + } + + private Event getEvent(String token) { + return eventRepository.findByToken(token) + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND)); + } +} diff --git a/server/src/main/java/server/haengdong/application/request/BillAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillAppRequest.java new file mode 100644 index 000000000..8eb945263 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/BillAppRequest.java @@ -0,0 +1,17 @@ +package server.haengdong.application.request; + +import java.util.List; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.event.Event; + +public record BillAppRequest( + String title, + Long price, + List memberIds +) { + + public Bill toBill(Event event, List members) { + return Bill.create(event, title, price, members); + } +} diff --git a/server/src/main/java/server/haengdong/application/request/BillDetailUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillDetailUpdateAppRequest.java new file mode 100644 index 000000000..4e66b9215 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/BillDetailUpdateAppRequest.java @@ -0,0 +1,8 @@ +package server.haengdong.application.request; + +public record BillDetailUpdateAppRequest( + Long id, + Long price, + boolean isFixed +) { +} diff --git a/server/src/main/java/server/haengdong/application/request/BillDetailsUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillDetailsUpdateAppRequest.java new file mode 100644 index 000000000..7a6477e08 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/BillDetailsUpdateAppRequest.java @@ -0,0 +1,8 @@ +package server.haengdong.application.request; + +import java.util.List; + +public record BillDetailsUpdateAppRequest( + List billDetailUpdateAppRequests +) { +} diff --git a/server/src/main/java/server/haengdong/application/request/BillUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillUpdateAppRequest.java new file mode 100644 index 000000000..aa09f2351 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/BillUpdateAppRequest.java @@ -0,0 +1,7 @@ +package server.haengdong.application.request; + +public record BillUpdateAppRequest( + String title, + Long price +) { +} diff --git a/server/src/main/java/server/haengdong/application/request/EventAppRequest.java b/server/src/main/java/server/haengdong/application/request/EventAppRequest.java new file mode 100644 index 000000000..20ec16d88 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/EventAppRequest.java @@ -0,0 +1,10 @@ +package server.haengdong.application.request; + +import server.haengdong.domain.event.Event; + +public record EventAppRequest(String name, String password) { + + public Event toEvent(String token) { + return new Event(name, password, token); + } +} diff --git a/server/src/main/java/server/haengdong/application/request/EventLoginAppRequest.java b/server/src/main/java/server/haengdong/application/request/EventLoginAppRequest.java new file mode 100644 index 000000000..947b5ef39 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/EventLoginAppRequest.java @@ -0,0 +1,4 @@ +package server.haengdong.application.request; + +public record EventLoginAppRequest(String token, String password) { +} diff --git a/server/src/main/java/server/haengdong/application/request/EventUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/EventUpdateAppRequest.java new file mode 100644 index 000000000..13bffccc1 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/EventUpdateAppRequest.java @@ -0,0 +1,17 @@ +package server.haengdong.application.request; + +public record EventUpdateAppRequest( + String eventName, + String bankName, + String accountNumber +) { + + public boolean isEventNameExist() { + return eventName != null && !eventName.trim().isEmpty(); + } + + public boolean isAccountExist() { + return bankName != null && !bankName.trim().isEmpty() + && accountNumber != null && !accountNumber.trim().isEmpty(); + } +} diff --git a/server/src/main/java/server/haengdong/application/request/MemberNameUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberNameUpdateAppRequest.java new file mode 100644 index 000000000..b36d71cc1 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/MemberNameUpdateAppRequest.java @@ -0,0 +1,7 @@ +package server.haengdong.application.request; + +public record MemberNameUpdateAppRequest( + Long id, + String name +) { +} diff --git a/server/src/main/java/server/haengdong/application/request/MemberNamesUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberNamesUpdateAppRequest.java new file mode 100644 index 000000000..cd0c00544 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/MemberNamesUpdateAppRequest.java @@ -0,0 +1,8 @@ +package server.haengdong.application.request; + +import java.util.List; + +public record MemberNamesUpdateAppRequest( + List members +) { +} diff --git a/server/src/main/java/server/haengdong/application/request/MemberSaveAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberSaveAppRequest.java new file mode 100644 index 000000000..45dc234e3 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/MemberSaveAppRequest.java @@ -0,0 +1,4 @@ +package server.haengdong.application.request; + +public record MemberSaveAppRequest(String name) { +} diff --git a/server/src/main/java/server/haengdong/application/request/MemberUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberUpdateAppRequest.java new file mode 100644 index 000000000..77df0625b --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/MemberUpdateAppRequest.java @@ -0,0 +1,16 @@ +package server.haengdong.application.request; + + +import server.haengdong.domain.member.Member; +import server.haengdong.domain.event.Event; + +public record MemberUpdateAppRequest( + Long id, + String name, + boolean isDeposited +) { + + public Member toMember(Event event) { + return new Member(id, event, name, isDeposited); + } +} diff --git a/server/src/main/java/server/haengdong/application/request/MembersSaveAppRequest.java b/server/src/main/java/server/haengdong/application/request/MembersSaveAppRequest.java new file mode 100644 index 000000000..ae0c9fc62 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/MembersSaveAppRequest.java @@ -0,0 +1,8 @@ +package server.haengdong.application.request; + +import java.util.List; + +public record MembersSaveAppRequest( + List members +) { +} diff --git a/server/src/main/java/server/haengdong/application/request/MembersUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MembersUpdateAppRequest.java new file mode 100644 index 000000000..aa3253504 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/MembersUpdateAppRequest.java @@ -0,0 +1,14 @@ +package server.haengdong.application.request; + +import java.util.List; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.member.Member; + +public record MembersUpdateAppRequest(List members) { + + public List toMembers(Event event) { + return members.stream() + .map(memberRequest -> memberRequest.toMember(event)) + .toList(); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/BillAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillAppResponse.java new file mode 100644 index 000000000..8a5a5b2bc --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/BillAppResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.bill.Bill; + +public record BillAppResponse( + Long id, + String title, + Long price, + boolean isFixed +) { + public static BillAppResponse of(Bill bill) { + return new BillAppResponse(bill.getId(), bill.getTitle(), bill.getPrice(), bill.isFixed()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/BillDetailAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillDetailAppResponse.java new file mode 100644 index 000000000..6618135cb --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/BillDetailAppResponse.java @@ -0,0 +1,20 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.bill.BillDetail; + +public record BillDetailAppResponse( + Long id, + String memberName, + Long price, + boolean isFixed +) { + + public static BillDetailAppResponse of(BillDetail billDetail) { + return new BillDetailAppResponse( + billDetail.getId(), + billDetail.getMember().getName(), + billDetail.getPrice(), + billDetail.isFixed() + ); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/BillDetailsAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillDetailsAppResponse.java new file mode 100644 index 000000000..3618e0d08 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/BillDetailsAppResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.application.response; + +import java.util.List; +import java.util.stream.Collectors; +import server.haengdong.domain.bill.BillDetail; + +public record BillDetailsAppResponse(List billDetails) { + + public static BillDetailsAppResponse of(List billDetails) { + return billDetails.stream() + .map(BillDetailAppResponse::of) + .collect(Collectors.collectingAndThen(Collectors.toList(), BillDetailsAppResponse::new)); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/EventAppResponse.java b/server/src/main/java/server/haengdong/application/response/EventAppResponse.java new file mode 100644 index 000000000..f331d0011 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/EventAppResponse.java @@ -0,0 +1,10 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.event.Event; + +public record EventAppResponse(String token) { + + public static EventAppResponse of(Event event) { + return new EventAppResponse(event.getToken()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/EventDetailAppResponse.java b/server/src/main/java/server/haengdong/application/response/EventDetailAppResponse.java new file mode 100644 index 000000000..899ea76ec --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/EventDetailAppResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.event.Event; + +public record EventDetailAppResponse( + String eventName, + String bankName, + String accountNumber +) { + + public static EventDetailAppResponse of(Event event) { + return new EventDetailAppResponse(event.getName(), event.getBankName(), event.getAccountNumber()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/LastBillMemberAppResponse.java b/server/src/main/java/server/haengdong/application/response/LastBillMemberAppResponse.java new file mode 100644 index 000000000..abefe009a --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/LastBillMemberAppResponse.java @@ -0,0 +1,10 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.member.Member; + +public record LastBillMemberAppResponse(Long id, String name) { + + public static LastBillMemberAppResponse of(Member member) { + return new LastBillMemberAppResponse(member.getId(), member.getName()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java new file mode 100644 index 000000000..b253ea697 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java @@ -0,0 +1,12 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.member.Member; + +public record MemberAppResponse( + Long id, + String name +) { + public static MemberAppResponse of(Member member) { + return new MemberAppResponse(member.getId(), member.getName()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/MemberBillReportAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberBillReportAppResponse.java new file mode 100644 index 000000000..875578fbc --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/MemberBillReportAppResponse.java @@ -0,0 +1,9 @@ +package server.haengdong.application.response; + +public record MemberBillReportAppResponse( + Long memberId, + String name, + boolean isDeposited, + Long price +) { +} diff --git a/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java new file mode 100644 index 000000000..94dd77117 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java @@ -0,0 +1,13 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.member.Member; + +public record MemberDepositAppResponse( + Long id, + String name, + boolean isDeposited +) { + public static MemberDepositAppResponse of(Member member) { + return new MemberDepositAppResponse(member.getId(), member.getName(), member.isDeposited()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java new file mode 100644 index 000000000..1d08536ba --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java @@ -0,0 +1,12 @@ +package server.haengdong.application.response; + +import server.haengdong.domain.member.Member; + +public record MemberSaveAppResponse( + Long id, + String name +) { + public static MemberSaveAppResponse of(Member member) { + return new MemberSaveAppResponse(member.getId(), member.getName()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/MembersDepositAppResponse.java b/server/src/main/java/server/haengdong/application/response/MembersDepositAppResponse.java new file mode 100644 index 000000000..007904d25 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/MembersDepositAppResponse.java @@ -0,0 +1,15 @@ +package server.haengdong.application.response; + +import java.util.List; +import server.haengdong.domain.member.Member; + +public record MembersDepositAppResponse( + List members +) { + + public static MembersDepositAppResponse of(List members) { + return new MembersDepositAppResponse(members.stream() + .map(MemberDepositAppResponse::of) + .toList()); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java b/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java new file mode 100644 index 000000000..e171f9950 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java @@ -0,0 +1,16 @@ +package server.haengdong.application.response; + +import java.util.List; +import server.haengdong.domain.member.Member; + +public record MembersSaveAppResponse( + List members +) { + public static MembersSaveAppResponse of(List members) { + return new MembersSaveAppResponse( + members.stream() + .map(MemberSaveAppResponse::of) + .toList() + ); + } +} diff --git a/server/src/main/java/server/haengdong/application/response/StepAppResponse.java b/server/src/main/java/server/haengdong/application/response/StepAppResponse.java new file mode 100644 index 000000000..2537d1db1 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/response/StepAppResponse.java @@ -0,0 +1,21 @@ +package server.haengdong.application.response; + +import java.util.List; +import server.haengdong.domain.step.Step; + +public record StepAppResponse( + List bills, + List members +) { + public static StepAppResponse of(Step step) { + List billAppResponses = step.getBills().stream() + .map(BillAppResponse::of) + .toList(); + + List memberAppResponses = step.getMembers().stream() + .map(MemberAppResponse::of) + .toList(); + + return new StepAppResponse(billAppResponses, memberAppResponses); + } +} diff --git a/server/src/main/java/server/haengdong/config/AdminInterceptor.java b/server/src/main/java/server/haengdong/config/AdminInterceptor.java new file mode 100644 index 000000000..acfe99789 --- /dev/null +++ b/server/src/main/java/server/haengdong/config/AdminInterceptor.java @@ -0,0 +1,56 @@ +package server.haengdong.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import server.haengdong.application.AuthService; +import server.haengdong.exception.AuthenticationException; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.infrastructure.auth.AuthenticationExtractor; + +@Slf4j +public class AdminInterceptor implements HandlerInterceptor { + + private static final String ADMIN_URI_REGEX = "/api/admin/events/([^/]+)"; + private static final Pattern ADMIN_URI_PATTERN = Pattern.compile(ADMIN_URI_REGEX); + private static final int EVENT_TOKEN_MATCHER_INDEX = 1; + + private final AuthService authService; + private final AuthenticationExtractor authenticationExtractor; + + public AdminInterceptor(AuthService authService, AuthenticationExtractor authenticationExtractor) { + this.authService = authService; + this.authenticationExtractor = authenticationExtractor; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + HttpMethod method = HttpMethod.valueOf(request.getMethod()); + if (HttpMethod.OPTIONS.equals(method)) { + return true; + } + validateToken(request); + return true; + } + + private void validateToken(HttpServletRequest request) { + String token = authenticationExtractor.extract(request, authService.getTokenName()); + String tokenEventId = authService.findEventIdByToken(token); + String uri = request.getRequestURI(); + + Matcher matcher = ADMIN_URI_PATTERN.matcher(uri); + if (!matcher.find()) { + throw new AuthenticationException(HaengdongErrorCode.FORBIDDEN); + } + + String eventToken = matcher.group(EVENT_TOKEN_MATCHER_INDEX); + if (!tokenEventId.equals(eventToken)) { + log.warn("[행사 접근 불가] Cookie EventId = {}, URL EventId = {}", tokenEventId, eventToken); + throw new AuthenticationException(HaengdongErrorCode.FORBIDDEN); + } + } +} diff --git a/server/src/main/java/server/haengdong/config/RequestServletFilter.java b/server/src/main/java/server/haengdong/config/RequestServletFilter.java new file mode 100644 index 000000000..b1afdb6f8 --- /dev/null +++ b/server/src/main/java/server/haengdong/config/RequestServletFilter.java @@ -0,0 +1,23 @@ +package server.haengdong.config; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import org.springframework.stereotype.Component; +import org.springframework.web.util.ContentCachingRequestWrapper; + +@Component +public class RequestServletFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper((HttpServletRequest) request); + + chain.doFilter(wrappedRequest, response); + } +} diff --git a/server/src/main/java/server/haengdong/config/WebMvcConfig.java b/server/src/main/java/server/haengdong/config/WebMvcConfig.java new file mode 100644 index 000000000..43f858db3 --- /dev/null +++ b/server/src/main/java/server/haengdong/config/WebMvcConfig.java @@ -0,0 +1,65 @@ +package server.haengdong.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import server.haengdong.application.AuthService; +import server.haengdong.domain.TokenProvider; +import server.haengdong.infrastructure.auth.AuthenticationExtractor; +import server.haengdong.infrastructure.auth.JwtTokenProvider; +import server.haengdong.infrastructure.auth.TokenProperties; + +@RequiredArgsConstructor +@EnableConfigurationProperties(TokenProperties.class) +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final TokenProperties tokenProperties; + + @Value("${cors.max-age}") + private Long maxAge; + + @Value("${cors.allowed-origins}") + private String[] allowedOrigins; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(maxAge); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor()) + .addPathPatterns("/api/admin/**"); + } + + @Bean + public AdminInterceptor adminInterceptor() { + return new AdminInterceptor(authService(), authenticationExtractor()); + } + + @Bean + public AuthService authService() { + return new AuthService(tokenProvider()); + } + + @Bean + public TokenProvider tokenProvider() { + return new JwtTokenProvider(tokenProperties); + } + + @Bean + public AuthenticationExtractor authenticationExtractor() { + return new AuthenticationExtractor(); + } +} diff --git a/server/src/main/java/server/haengdong/domain/TokenProvider.java b/server/src/main/java/server/haengdong/domain/TokenProvider.java new file mode 100644 index 000000000..28e7956c3 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/TokenProvider.java @@ -0,0 +1,12 @@ +package server.haengdong.domain; + +import java.util.Map; + +public interface TokenProvider { + + String createToken(Map payload); + + Map getPayload(String token); + + boolean validateToken(String token); +} diff --git a/server/src/main/java/server/haengdong/domain/bill/Bill.java b/server/src/main/java/server/haengdong/domain/bill/Bill.java new file mode 100644 index 000000000..6face8274 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/bill/Bill.java @@ -0,0 +1,158 @@ +package server.haengdong.domain.bill; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.event.Event; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Bill { + + private static final int MIN_TITLE_LENGTH = 1; + private static final int MAX_TITLE_LENGTH = 30; + private static final long MIN_PRICE = 1L; + private static final long MAX_PRICE = 10_000_000L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "event_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Event event; + + @Column(nullable = false, length = MAX_TITLE_LENGTH) + private String title; + + @Column(nullable = false) + private Long price; + + @OneToMany(mappedBy = "bill", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List billDetails = new ArrayList<>(); + + public Bill(Event event, String title, Long price) { + validateTitle(title); + validatePrice(price); + this.event = event; + this.title = title.trim(); + this.price = price; + } + + private void validateTitle(String title) { + int titleLength = title.trim().length(); + if (titleLength < MIN_TITLE_LENGTH || titleLength > MAX_TITLE_LENGTH) { + throw new HaengdongException(HaengdongErrorCode.BILL_TITLE_INVALID, MIN_TITLE_LENGTH, MAX_TITLE_LENGTH); + } + } + + private void validatePrice(Long price) { + if (price < MIN_PRICE || price > MAX_PRICE) { + throw new HaengdongException(HaengdongErrorCode.BILL_PRICE_INVALID, MAX_PRICE); + } + } + + public static Bill create(Event event, String title, Long price, List members) { + Bill bill = new Bill(event, title, price); + bill.resetBillDetails(members); + return bill; + } + + public void resetBillDetails(List members) { + this.billDetails.clear(); + Iterator priceIterator = distributePrice(members.size()).iterator(); + + for (Member member : members) { + BillDetail billDetail = new BillDetail(this, member, priceIterator.next(), false); + this.billDetails.add(billDetail); + } + } + + private void resetBillDetails() { + Iterator priceIterator = distributePrice(billDetails.size()).iterator(); + + billDetails.forEach(billDetail -> { + billDetail.updatePrice(priceIterator.next()); + billDetail.updateIsFixed(false); + }); + } + + private List distributePrice(int memberCount) { + if (memberCount == 0) { + return new ArrayList<>(); + } + long eachPrice = price / memberCount; + long remainder = price % memberCount; + + List results = Stream.generate(() -> eachPrice) + .limit(memberCount - 1) + .collect(Collectors.toList()); + results.add(eachPrice + remainder); + return results; + } + + public void removeMemberBillDetail(Member member) { + BillDetail foundBillDetail = billDetails.stream() + .filter(billDetail -> billDetail.isMember(member)) + .findFirst() + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.MEMBER_NOT_FOUND)); + + billDetails.remove(foundBillDetail); + resetBillDetails(); + } + + public void update(String title, Long price) { + validateTitle(title); + validatePrice(price); + this.title = title.trim(); + this.price = price; + resetBillDetails(); + } + + public boolean containMember(Member member) { + return billDetails.stream() + .anyMatch(billDetail -> billDetail.isMember(member)); + } + + public boolean isSameMembers(Bill other) { + Set members = Set.copyOf(this.getMembers()); + Set otherMembers = Set.copyOf(other.getMembers()); + + return members.equals(otherMembers); + } + + public boolean isSamePrice(Long price) { + return this.price.equals(price); + } + + public boolean isFixed() { + return billDetails.stream() + .anyMatch(BillDetail::isFixed); + } + + public List getMembers() { + return billDetails.stream() + .map(BillDetail::getMember) + .toList(); + } +} diff --git a/server/src/main/java/server/haengdong/domain/bill/BillDetail.java b/server/src/main/java/server/haengdong/domain/bill/BillDetail.java new file mode 100644 index 000000000..6f3bcf973 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/bill/BillDetail.java @@ -0,0 +1,61 @@ +package server.haengdong.domain.bill; + +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.Getter; +import lombok.NoArgsConstructor; +import server.haengdong.domain.member.Member; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class BillDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Bill bill; + + @JoinColumn(name = "member_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @Column(nullable = false) + private Long price; + + @Column(nullable = false) + private boolean isFixed; + + public BillDetail(Bill bill, Member member, Long price, boolean isFixed) { + this.bill = bill; + this.member = member; + this.price = price; + this.isFixed = isFixed; + } + + public void updatePrice(Long price) { + this.price = price; + } + + public void updateIsFixed(boolean isFixed) { + this.isFixed = isFixed; + } + + public boolean isSameId(Long id) { + return this.id.equals(id); + } + + public boolean isMember(Member member) { + return this.member.equals(member); + } +} diff --git a/server/src/main/java/server/haengdong/domain/bill/BillRepository.java b/server/src/main/java/server/haengdong/domain/bill/BillRepository.java new file mode 100644 index 000000000..23efe487c --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/bill/BillRepository.java @@ -0,0 +1,23 @@ +package server.haengdong.domain.bill; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import server.haengdong.domain.event.Event; + +@Repository +public interface BillRepository extends JpaRepository { + + @Query(""" + select b + from Bill b + join fetch b.billDetails bd + join fetch bd.member + where b.event = :event + """) + List findAllByEvent(Event event); + + Optional findFirstByEventOrderByIdDesc(Event event); +} diff --git a/server/src/main/java/server/haengdong/domain/bill/MemberBillReport.java b/server/src/main/java/server/haengdong/domain/bill/MemberBillReport.java new file mode 100644 index 000000000..ee16824d0 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/bill/MemberBillReport.java @@ -0,0 +1,30 @@ +package server.haengdong.domain.bill; + +import static java.util.stream.Collectors.toMap; + +import java.util.List; +import java.util.Map; +import lombok.Getter; +import server.haengdong.domain.member.Member; + +@Getter +public class MemberBillReport { + + private final Map reports; + + private MemberBillReport(Map reports) { + this.reports = reports; + } + + public static MemberBillReport createByBills(List bills) { + Map reports = bills.stream() + .flatMap(bill -> bill.getBillDetails().stream()) + .collect(toMap( + BillDetail::getMember, + BillDetail::getPrice, + Long::sum + )); + + return new MemberBillReport(reports); + } +} diff --git a/server/src/main/java/server/haengdong/domain/event/Bank.java b/server/src/main/java/server/haengdong/domain/event/Bank.java new file mode 100644 index 000000000..6e449ae7e --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/Bank.java @@ -0,0 +1,56 @@ +package server.haengdong.domain.event; + +import java.util.Arrays; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +public enum Bank { + WOORI_BANK("우리은행"), + JEIL_BANK("제일은행"), + SHINHAN_BANK("신한은행"), + KB_BANK("KB국민은행"), + HANA_BANK("하나은행"), + CITI_BANK("시티은행"), + IM_BANK("IM뱅크"), + BUSAN_BANK("부산은행"), + GYEONGNAM_BANK("경남은행"), + GWANGJU_BANK("광주은행"), + JEONBUK_BANK("전북은행"), + JEJU_BANK("제주은행"), + IBK_BANK("기업은행"), + KDB_BANK("산업은행"), + SUHYUP_BANK("수협은행"), + NH_BANK("농협은행"), + SAEMAUL_BANK("새마을금고"), + POST_BANK("우체국은행"), + SHINHYEOP_BANK("신협은행"), + SBI_SAVINGS_BANK("SBI저축"), + KAKAO_BANK("카카오뱅크"), + TOSS_BANK("토스뱅크"), + K_BANK("케이뱅크"), + ; + + private final String name; + + Bank(String name) { + this.name = name; + } + + public static void isExists(String bankName) { + Arrays.stream(Bank.values()) + .filter(bank -> bank.name.equals(bankName)) + .findFirst() + .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BANK_NAME_INVALID, getSupportedBanks())); + } + + private static String getSupportedBanks() { + return Arrays.stream(Bank.values()) + .map(Bank::getName) + .reduce((bank1, bank2) -> bank1 + ", " + bank2) + .orElse("지원하는 은행이 없습니다."); + } + + public String getName() { + return name; + } +} diff --git a/server/src/main/java/server/haengdong/domain/event/Event.java b/server/src/main/java/server/haengdong/domain/event/Event.java new file mode 100644 index 000000000..58d080a43 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/Event.java @@ -0,0 +1,115 @@ +package server.haengdong.domain.event; + +import jakarta.persistence.AttributeOverride; +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 java.util.Arrays; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Event { + + private static final int MIN_NAME_LENGTH = 1; + private static final int MAX_NAME_LENGTH = 20; + private static final int MIN_ACCOUNT_NUMBER_LENGTH = 8; + private static final int MAX_ACCOUNT_NUMBER_LENGTH = 30; + private static final String SPACES = " "; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = MAX_NAME_LENGTH) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password", nullable = false)) + private Password password; + + @Column(nullable = false, unique = true) + private String token; + + @Column(length = MAX_ACCOUNT_NUMBER_LENGTH) + private String account; + + + public Event(String name, String password, String token) { + validateName(name); + this.name = name; + this.password = new Password(password); + this.token = token; + this.account = ""; + } + + private void validateName(String name) { + int nameLength = name.trim().length(); + if (nameLength < MIN_NAME_LENGTH || MAX_NAME_LENGTH < nameLength) { + throw new HaengdongException( + HaengdongErrorCode.EVENT_NAME_LENGTH_INVALID, MIN_NAME_LENGTH, MAX_NAME_LENGTH); + } + if (isBlankContinuous(name)) { + throw new HaengdongException(HaengdongErrorCode.EVENT_NAME_CONSECUTIVE_SPACES); + } + } + + private boolean isBlankContinuous(String name) { + return name.contains(SPACES); + } + + public boolean isTokenMismatch(String token) { + return !this.token.equals(token); + } + + public boolean isPasswordMismatch(String rawPassword) { + return !password.matches(rawPassword); + } + + public void rename(String name) { + validateName(name); + this.name = name; + } + + public void changeAccount(String bankName, String accountNumber) { + validateBankName(bankName); + validateAccountNumber(accountNumber); + this.account = bankName + " " + accountNumber; + } + + private void validateBankName(String bankName) { + Bank.isExists(bankName); + } + + private void validateAccountNumber(String accountNumber) { + int accountLength = accountNumber.trim().length(); + if (accountLength < MIN_ACCOUNT_NUMBER_LENGTH || MAX_ACCOUNT_NUMBER_LENGTH < accountLength) { + throw new HaengdongException( + HaengdongErrorCode.ACCOUNT_LENGTH_INVALID, MIN_ACCOUNT_NUMBER_LENGTH, MAX_ACCOUNT_NUMBER_LENGTH); + } + } + + public String getBankName() { + String[] bankNameAndAccountNumber = account.split(" "); + if (bankNameAndAccountNumber.length > 0) { + return bankNameAndAccountNumber[0]; + } + return ""; + } + + public String getAccountNumber() { + String[] bankNameAndAccountNumber = account.split(" "); + if (bankNameAndAccountNumber.length > 1) { + return String.join(" ", Arrays.copyOfRange(bankNameAndAccountNumber, 1, bankNameAndAccountNumber.length)); + } + return ""; + } +} diff --git a/server/src/main/java/server/haengdong/domain/event/EventImage.java b/server/src/main/java/server/haengdong/domain/event/EventImage.java new file mode 100644 index 000000000..cc5aad0dd --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/EventImage.java @@ -0,0 +1,30 @@ +package server.haengdong.domain.event; + +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.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class EventImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "event_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Event event; + + @Column(nullable = false) + private String url; +} diff --git a/server/src/main/java/server/haengdong/domain/event/EventRepository.java b/server/src/main/java/server/haengdong/domain/event/EventRepository.java new file mode 100644 index 000000000..09526125e --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/EventRepository.java @@ -0,0 +1,11 @@ +package server.haengdong.domain.event; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EventRepository extends JpaRepository { + + Optional findByToken(String token); +} diff --git a/server/src/main/java/server/haengdong/domain/event/EventTokenProvider.java b/server/src/main/java/server/haengdong/domain/event/EventTokenProvider.java new file mode 100644 index 000000000..6450f0dcf --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/EventTokenProvider.java @@ -0,0 +1,12 @@ +package server.haengdong.domain.event; + +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class EventTokenProvider { + + public String createToken() { + return UUID.randomUUID().toString(); + } +} diff --git a/server/src/main/java/server/haengdong/domain/event/Password.java b/server/src/main/java/server/haengdong/domain/event/Password.java new file mode 100644 index 000000000..7c195b5d5 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/event/Password.java @@ -0,0 +1,52 @@ +package server.haengdong.domain.event; + +import jakarta.persistence.Embeddable; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Embeddable +public class Password { + + private static final int PASSWORD_LENGTH = 4; + private static final Pattern PASSWORD_PATTERN = Pattern.compile(String.format("^\\d{%d}$", PASSWORD_LENGTH)); + private static final String HASH_ALGORITHM = "SHA-256"; + + private String value; + + public Password(String password) { + validatePassword(password); + this.value = encode(password); + } + + private void validatePassword(String password) { + Matcher matcher = PASSWORD_PATTERN.matcher(password); + if (!matcher.matches()) { + throw new HaengdongException(HaengdongErrorCode.EVENT_PASSWORD_FORMAT_INVALID, PASSWORD_LENGTH); + } + } + + private String encode(String rawPassword) { + try { + MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM); + byte[] hashedPassword = digest.digest(rawPassword.getBytes()); + return Base64.getEncoder().encodeToString(hashedPassword); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("해시 알고리즘이 존재하지 않습니다."); + } + } + + public boolean matches(String rawPassword) { + String hashedPassword = encode(rawPassword); + return value.equals(hashedPassword); + } +} diff --git a/server/src/main/java/server/haengdong/domain/member/Member.java b/server/src/main/java/server/haengdong/domain/member/Member.java new file mode 100644 index 000000000..25a19ccb0 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/member/Member.java @@ -0,0 +1,84 @@ +package server.haengdong.domain.member; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import server.haengdong.domain.event.Event; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"event_id", "name"})}) +@Entity +public class Member { + + private static final int MIN_NAME_LENGTH = 1; + private static final int MAX_NAME_LENGTH = 8; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "event_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Event event; + + @Column(nullable = false, length = MAX_NAME_LENGTH) + private String name; + + @Column(nullable = false) + private boolean isDeposited; + + public Member(Event event, String name) { + this(null, event, name, false); + } + + public Member(Long id, Event event, String name, boolean isDeposited) { + validateName(name); + this.id = id; + this.event = event; + this.name = name; + this.isDeposited = isDeposited; + } + + private void validateName(String name) { + int nameLength = name.length(); + if (nameLength < MIN_NAME_LENGTH || nameLength > MAX_NAME_LENGTH) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_LENGTH_INVALID, MIN_NAME_LENGTH, + MAX_NAME_LENGTH); + } + } + + public boolean hasName(String name) { + return this.name.equals(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Member member = (Member) o; + return Objects.equals(id, member.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/server/src/main/java/server/haengdong/domain/member/MemberRepository.java b/server/src/main/java/server/haengdong/domain/member/MemberRepository.java new file mode 100644 index 000000000..15d57f4ba --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/member/MemberRepository.java @@ -0,0 +1,10 @@ +package server.haengdong.domain.member; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import server.haengdong.domain.event.Event; + +public interface MemberRepository extends JpaRepository { + + List findAllByEvent(Event event); +} diff --git a/server/src/main/java/server/haengdong/domain/member/UpdatedMembers.java b/server/src/main/java/server/haengdong/domain/member/UpdatedMembers.java new file mode 100644 index 000000000..3a588c077 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/member/UpdatedMembers.java @@ -0,0 +1,67 @@ +package server.haengdong.domain.member; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +public class UpdatedMembers { + + private final Set members; + + public UpdatedMembers(List members) { + validateMemberUnique(members); + validateNameUnique(members); + this.members = new HashSet<>(members); + } + + private void validateMemberUnique(List members) { + if (members.size() != Set.copyOf(members).size()) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_CHANGE_DUPLICATE); + } + } + + private void validateNameUnique(List members) { + Set uniqueNames = members.stream() + .map(Member::getName) + .collect(Collectors.toSet()); + if (members.size() != uniqueNames.size()) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_CHANGE_DUPLICATE); + } + } + + public void validateUpdatable(List originMembers) { + Set uniqueMembers = Set.copyOf(originMembers); + validateUpdatedMembersExist(uniqueMembers); + validateUpdatedNamesUnique(uniqueMembers); + } + + private void validateUpdatedMembersExist(Set originMembers) { + if (!this.members.equals(originMembers)) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_UPDATE_MISMATCH); + } + } + + private void validateUpdatedNamesUnique(Set originMembers) { + boolean duplicated = originMembers.stream() + .anyMatch(this::isMemberNameUpdated); + + if (duplicated) { + throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_DUPLICATE); + } + } + + private boolean isMemberNameUpdated(Member originMembers) { + return this.members.stream() + .filter(member -> !member.getId().equals(originMembers.getId())) + .anyMatch(member -> member.hasName(originMembers.getName())); + } + + public List getMembers() { + return members.stream().toList(); + } +} + + diff --git a/server/src/main/java/server/haengdong/domain/step/Step.java b/server/src/main/java/server/haengdong/domain/step/Step.java new file mode 100644 index 000000000..834d3e715 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/step/Step.java @@ -0,0 +1,50 @@ +package server.haengdong.domain.step; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; + +public class Step { + + private final List bills; + private final Set members; + + private Step(List bills, Set members) { + this.bills = bills; + this.members = members; + } + + public static Step of(Bill bill) { + List bills = new ArrayList<>(); + bills.add(bill); + Set members = new HashSet<>(bill.getMembers()); + + return new Step(bills, members); + } + + public void add(Bill bill) { + if (isNotSameMember(bill)) { + throw new HaengdongException(HaengdongErrorCode.DIFFERENT_STEP_MEMBERS); + } + + bills.add(bill); + } + + public boolean isNotSameMember(Bill bill) { + Set otherMembers = Set.copyOf(bill.getMembers()); + return !members.equals(otherMembers); + } + + public List getBills() { + return bills; + } + + public Set getMembers() { + return members; + } +} diff --git a/server/src/main/java/server/haengdong/domain/step/Steps.java b/server/src/main/java/server/haengdong/domain/step/Steps.java new file mode 100644 index 000000000..f1ad8c746 --- /dev/null +++ b/server/src/main/java/server/haengdong/domain/step/Steps.java @@ -0,0 +1,33 @@ +package server.haengdong.domain.step; + +import java.util.ArrayList; +import java.util.List; +import server.haengdong.domain.bill.Bill; + +public class Steps { + + private final List steps; + + private Steps(List steps) { + this.steps = steps; + } + + public static Steps of(List bills) { + List steps = new ArrayList<>(); + Step currentStep = null; + + for (Bill bill : bills) { + if (currentStep == null || currentStep.isNotSameMember(bill)) { + currentStep = Step.of(bill); + steps.add(currentStep); + continue; + } + currentStep.add(bill); + } + return new Steps(steps); + } + + public List getSteps() { + return steps; + } +} diff --git a/server/src/main/java/server/haengdong/exception/AuthenticationException.java b/server/src/main/java/server/haengdong/exception/AuthenticationException.java new file mode 100644 index 000000000..2efcb16e7 --- /dev/null +++ b/server/src/main/java/server/haengdong/exception/AuthenticationException.java @@ -0,0 +1,19 @@ +package server.haengdong.exception; + +import lombok.Getter; + +@Getter +public class AuthenticationException extends RuntimeException { + + private final HaengdongErrorCode errorCode; + private final String message; + + public AuthenticationException(HaengdongErrorCode errorCode) { + this(errorCode, errorCode.getMessage()); + } + + public AuthenticationException(HaengdongErrorCode errorCode, String message) { + this.errorCode = errorCode; + this.message = message; + } +} diff --git a/server/src/main/java/server/haengdong/exception/ErrorResponse.java b/server/src/main/java/server/haengdong/exception/ErrorResponse.java new file mode 100644 index 000000000..3937f4322 --- /dev/null +++ b/server/src/main/java/server/haengdong/exception/ErrorResponse.java @@ -0,0 +1,15 @@ +package server.haengdong.exception; + +public record ErrorResponse( + String errorCode, + String message +) { + + public static ErrorResponse of(HaengdongErrorCode errorCode) { + return new ErrorResponse(errorCode.name(), errorCode.getMessage()); + } + + public static ErrorResponse of(HaengdongErrorCode errorCode, String message) { + return new ErrorResponse(errorCode.name(), message); + } +} diff --git a/server/src/main/java/server/haengdong/exception/GlobalExceptionHandler.java b/server/src/main/java/server/haengdong/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..4d2c96c73 --- /dev/null +++ b/server/src/main/java/server/haengdong/exception/GlobalExceptionHandler.java @@ -0,0 +1,90 @@ +package server.haengdong.exception; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.io.IOException; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final String LOG_FORMAT = """ + \n\t{ + "RequestURI": "{} {}", + "RequestBody": {}, + "ErrorMessage": "{}" + \t} + """; + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity authenticationException(HttpServletRequest req, AuthenticationException e) { + log.warn(LOG_FORMAT, req.getMethod(), req.getRequestURI(), getRequestBody(req), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of(e.getErrorCode())); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity noResourceException(HttpRequestMethodNotSupportedException e) { + log.warn(e.getMessage(), e); + return ResponseEntity.badRequest() + .body(ErrorResponse.of(HaengdongErrorCode.REQUEST_METHOD_NOT_SUPPORTED)); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity noResourceException(NoResourceFoundException e) { + log.warn(e.getMessage(), e); + return ResponseEntity.badRequest() + .body(ErrorResponse.of(HaengdongErrorCode.NO_RESOURCE_REQUEST)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity httpMessageNotReadableException(HttpMessageNotReadableException e) { + log.warn(e.getMessage(), e); + return ResponseEntity.badRequest() + .body(ErrorResponse.of(HaengdongErrorCode.MESSAGE_NOT_READABLE)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.warn(e.getMessage(), e); + String errorMessage = e.getFieldErrors().stream() + .map(error -> error.getField() + " " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + return ResponseEntity.badRequest() + .body(ErrorResponse.of(HaengdongErrorCode.REQUEST_EMPTY, errorMessage)); + } + + @ExceptionHandler(HaengdongException.class) + public ResponseEntity haengdongException(HttpServletRequest req, HaengdongException e) { + log.warn(LOG_FORMAT, req.getMethod(), req.getRequestURI(), getRequestBody(req), e.getMessage(), e); + return ResponseEntity.badRequest() + .body(ErrorResponse.of(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(HttpServletRequest req, Exception e) { + log.error(LOG_FORMAT, req.getMethod(), req.getRequestURI(), getRequestBody(req), e.getMessage(), e); + return ResponseEntity.internalServerError() + .body(ErrorResponse.of(HaengdongErrorCode.INTERNAL_SERVER_ERROR)); + } + + private String getRequestBody(HttpServletRequest req) { + try (BufferedReader reader = req.getReader()) { + return reader.lines().collect(Collectors.joining(System.lineSeparator() + "\t")); + } catch (IOException e) { + log.error("Failed to read request body", e); + return ""; + } + } +} diff --git a/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java new file mode 100644 index 000000000..a59288f4b --- /dev/null +++ b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java @@ -0,0 +1,58 @@ +package server.haengdong.exception; + +import lombok.Getter; + +@Getter +public enum HaengdongErrorCode { + + /* Domain */ + + EVENT_NOT_FOUND("존재하지 않는 행사입니다."), + EVENT_NAME_LENGTH_INVALID("행사 이름은 %d자 이상 %d자 이하만 입력 가능합니다."), + EVENT_NAME_CONSECUTIVE_SPACES("행사 이름에는 공백 문자가 연속될 수 없습니다."), + EVENT_PASSWORD_FORMAT_INVALID("비밀번호는 %d자리 숫자만 가능합니다."), + BANK_NAME_INVALID("지원하지 않는 은행입니다. 지원하는 은행 목록: %s"), + ACCOUNT_LENGTH_INVALID("계좌번호는 %d자 이상 %d자 이하만 입력 가능합니다."), + + MEMBER_NAME_LENGTH_INVALID("참여자 이름은 %d자 이상 %d자 이하만 입력 가능합니다."), + MEMBER_NAME_DUPLICATE("행사에 중복된 참여자 이름이 존재합니다."), + MEMBER_NOT_FOUND("존재하지 않는 참여자입니다."), + MEMBER_ALREADY_EXIST("현재 참여하고 있는 인원이 존재합니다."), + MEMBER_NAME_CHANGE_DUPLICATE("중복된 참여 인원 이름 변경 요청이 존재합니다."), + MEMBER_UPDATE_MISMATCH("업데이트 요청된 참여자 ID 목록과 기존 행사 참여자 ID 목록이 일치하지 않습니다."), + + BILL_NOT_FOUND("존재하지 않는 지출입니다."), + BILL_TITLE_INVALID("앞뒤 공백을 제거한 지출 내역 제목은 %d ~ %d자여야 합니다."), + BILL_PRICE_INVALID("지출 금액은 %,d 이하의 자연수여야 합니다."), + BILL_DETAIL_NOT_FOUND("존재하지 않는 참여자 지출입니다."), + BILL_PRICE_NOT_MATCHED("지출 총액이 일치하지 않습니다."), + + DIFFERENT_STEP_MEMBERS("참여자 목록이 일치하지 않습니다."), + + /* Authentication */ + + PASSWORD_INVALID("비밀번호가 일치하지 않습니다."), + + TOKEN_NOT_FOUND("토큰이 존재하지 않습니다."), + TOKEN_INVALID("유효하지 않은 토큰입니다."), + + FORBIDDEN("접근할 수 없는 행사입니다."), + + /* Request Validation */ + + REQUEST_EMPTY("입력 값은 공백일 수 없습니다.") + + /* System */, + + MESSAGE_NOT_READABLE("읽을 수 없는 요청입니다."), + REQUEST_METHOD_NOT_SUPPORTED("지원하지 않는 요청 메서드입니다."), + NO_RESOURCE_REQUEST("존재하지 않는 자원입니다."), + INTERNAL_SERVER_ERROR("서버 내부에서 에러가 발생했습니다."), + ; + + private final String message; + + HaengdongErrorCode(String message) { + this.message = message; + } +} diff --git a/server/src/main/java/server/haengdong/exception/HaengdongException.java b/server/src/main/java/server/haengdong/exception/HaengdongException.java new file mode 100644 index 000000000..4454fe7bf --- /dev/null +++ b/server/src/main/java/server/haengdong/exception/HaengdongException.java @@ -0,0 +1,19 @@ +package server.haengdong.exception; + +import lombok.Getter; + +@Getter +public class HaengdongException extends RuntimeException { + + private final HaengdongErrorCode errorCode; + + public HaengdongException(HaengdongErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public HaengdongException(HaengdongErrorCode errorCode, Object... args) { + super(String.format(errorCode.getMessage(), args)); + this.errorCode = errorCode; + } +} diff --git a/server/src/main/java/server/haengdong/infrastructure/DataSourceConfig.java b/server/src/main/java/server/haengdong/infrastructure/DataSourceConfig.java new file mode 100644 index 000000000..c859217b4 --- /dev/null +++ b/server/src/main/java/server/haengdong/infrastructure/DataSourceConfig.java @@ -0,0 +1,70 @@ +package server.haengdong.infrastructure; + +import com.zaxxer.hikari.HikariDataSource; +import jakarta.persistence.EntityManagerFactory; +import java.util.HashMap; +import java.util.Map; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; + +@Profile("prod") +@Configuration +public class DataSourceConfig { + + private static final String PRIMARY = "primary"; + private static final String SECONDARY = "secondary"; + + @ConfigurationProperties(prefix = "spring.datasource.hikari.primary") + @Bean + public DataSource primaryDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } + + @ConfigurationProperties(prefix = "spring.datasource.hikari.secondary") + @Bean + public DataSource secondaryDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } + + @DependsOn({"primaryDataSource", "secondaryDataSource"}) + @Bean + public DataSource routingDataSource( + @Qualifier("primaryDataSource") DataSource primary, + @Qualifier("secondaryDataSource") DataSource secondary) { + DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource(); + + Map dataSourceMap = new HashMap<>(); + + dataSourceMap.put(PRIMARY, primary); + dataSourceMap.put(SECONDARY, secondary); + + routingDataSource.setTargetDataSources(dataSourceMap); + routingDataSource.setDefaultTargetDataSource(primary); + + return routingDataSource; + } + + @DependsOn({"routingDataSource"}) + @Primary + @Bean + public DataSource dataSource(DataSource routingDataSource) { + return new LazyConnectionDataSourceProxy(routingDataSource); + } + + @Bean + public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + JpaTransactionManager jpaTransactionManager = new JpaTransactionManager(); + jpaTransactionManager.setEntityManagerFactory(entityManagerFactory); + return jpaTransactionManager; + } +} diff --git a/server/src/main/java/server/haengdong/infrastructure/DynamicRoutingDataSource.java b/server/src/main/java/server/haengdong/infrastructure/DynamicRoutingDataSource.java new file mode 100644 index 000000000..5f84010a1 --- /dev/null +++ b/server/src/main/java/server/haengdong/infrastructure/DynamicRoutingDataSource.java @@ -0,0 +1,18 @@ +package server.haengdong.infrastructure; + +import static org.springframework.transaction.support.TransactionSynchronizationManager.isCurrentTransactionReadOnly; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; + +@Slf4j +public class DynamicRoutingDataSource extends AbstractRoutingDataSource { + + private static final String PRIMARY = "primary"; + private static final String SECONDARY = "secondary"; + + @Override + protected Object determineCurrentLookupKey() { + return isCurrentTransactionReadOnly() ? SECONDARY : PRIMARY; + } +} diff --git a/server/src/main/java/server/haengdong/infrastructure/auth/AuthenticationExtractor.java b/server/src/main/java/server/haengdong/infrastructure/auth/AuthenticationExtractor.java new file mode 100644 index 000000000..4972de48a --- /dev/null +++ b/server/src/main/java/server/haengdong/infrastructure/auth/AuthenticationExtractor.java @@ -0,0 +1,23 @@ +package server.haengdong.infrastructure.auth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import server.haengdong.exception.AuthenticationException; +import server.haengdong.exception.HaengdongErrorCode; + +public class AuthenticationExtractor { + + public String extract(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + throw new AuthenticationException(HaengdongErrorCode.TOKEN_NOT_FOUND); + } + + return Arrays.stream(cookies) + .filter(cookie -> cookieName.equals(cookie.getName())) + .findFirst() + .orElseThrow(() -> new AuthenticationException(HaengdongErrorCode.TOKEN_NOT_FOUND)) + .getValue(); + } +} diff --git a/server/src/main/java/server/haengdong/infrastructure/auth/CookieProperties.java b/server/src/main/java/server/haengdong/infrastructure/auth/CookieProperties.java new file mode 100644 index 000000000..18f867601 --- /dev/null +++ b/server/src/main/java/server/haengdong/infrastructure/auth/CookieProperties.java @@ -0,0 +1,15 @@ +package server.haengdong.infrastructure.auth; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("cookie") +public record CookieProperties( + boolean httpOnly, + boolean secure, + String domain, + String path, + String sameSite, + Duration maxAge +) { +} diff --git a/server/src/main/java/server/haengdong/infrastructure/auth/JwtTokenProvider.java b/server/src/main/java/server/haengdong/infrastructure/auth/JwtTokenProvider.java new file mode 100644 index 000000000..df9ec6a81 --- /dev/null +++ b/server/src/main/java/server/haengdong/infrastructure/auth/JwtTokenProvider.java @@ -0,0 +1,54 @@ +package server.haengdong.infrastructure.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import server.haengdong.domain.TokenProvider; + +public class JwtTokenProvider implements TokenProvider { + + private final TokenProperties tokenProperties; + + public JwtTokenProvider(TokenProperties tokenProperties) { + this.tokenProperties = tokenProperties; + } + + @Override + public String createToken(Map payload) { + Claims claims = Jwts.claims(new HashMap<>(payload)); + Date now = new Date(); + Date validity = new Date(now.getTime() + tokenProperties.expireLength()); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(SignatureAlgorithm.HS256, tokenProperties.secretKey()) + .compact(); + } + + @Override + public Map getPayload(String token) { + return Jwts.parser() + .setSigningKey(tokenProperties.secretKey()) + .parseClaimsJws(token) + .getBody(); + } + + @Override + public boolean validateToken(String token) { + try { + Jws claims = Jwts.parser().setSigningKey(tokenProperties.secretKey()).parseClaimsJws(token); + + return !claims.getBody().getExpiration().before(new Date()); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } +} + diff --git a/server/src/main/java/server/haengdong/infrastructure/auth/TokenProperties.java b/server/src/main/java/server/haengdong/infrastructure/auth/TokenProperties.java new file mode 100644 index 000000000..11dedcdbf --- /dev/null +++ b/server/src/main/java/server/haengdong/infrastructure/auth/TokenProperties.java @@ -0,0 +1,7 @@ +package server.haengdong.infrastructure.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("security.jwt.token") +public record TokenProperties(String secretKey, Long expireLength) { +} diff --git a/server/src/main/java/server/haengdong/presentation/BillController.java b/server/src/main/java/server/haengdong/presentation/BillController.java new file mode 100644 index 000000000..f1a4f74f4 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/BillController.java @@ -0,0 +1,35 @@ +package server.haengdong.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import server.haengdong.application.BillService; +import server.haengdong.application.response.BillDetailsAppResponse; +import server.haengdong.presentation.response.BillDetailsResponse; +import server.haengdong.presentation.response.StepsResponse; + +@RequiredArgsConstructor +@RestController +public class BillController { + + private final BillService billService; + + @GetMapping("/api/events/{eventId}/bills") + public ResponseEntity findBills(@PathVariable("eventId") String token) { + StepsResponse stepsResponse = StepsResponse.of(billService.findSteps(token)); + + return ResponseEntity.ok(stepsResponse); + } + + @GetMapping("/api/events/{eventId}/bills/{billId}/details") + public ResponseEntity findBillDetails( + @PathVariable("eventId") String token, + @PathVariable("billId") Long billId + ) { + BillDetailsAppResponse appResponse = billService.findBillDetails(token, billId); + + return ResponseEntity.ok(BillDetailsResponse.of(appResponse)); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/EventController.java b/server/src/main/java/server/haengdong/presentation/EventController.java new file mode 100644 index 000000000..77caa744a --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/EventController.java @@ -0,0 +1,87 @@ +package server.haengdong.presentation; + +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import server.haengdong.application.AuthService; +import server.haengdong.application.EventService; +import server.haengdong.application.response.MemberBillReportAppResponse; +import server.haengdong.infrastructure.auth.CookieProperties; +import server.haengdong.presentation.request.EventLoginRequest; +import server.haengdong.presentation.request.EventSaveRequest; +import server.haengdong.presentation.response.EventDetailResponse; +import server.haengdong.presentation.response.EventResponse; +import server.haengdong.presentation.response.MemberBillReportsResponse; + +@Slf4j +@RequiredArgsConstructor +@EnableConfigurationProperties(CookieProperties.class) +@RestController +public class EventController { + + private final EventService eventService; + private final AuthService authService; + private final CookieProperties cookieProperties; + + @GetMapping("/api/events/{eventId}") + public ResponseEntity findEvent(@PathVariable("eventId") String token) { + EventDetailResponse eventDetailResponse = EventDetailResponse.of(eventService.findEvent(token)); + + return ResponseEntity.ok(eventDetailResponse); + } + + @GetMapping("/api/events/{eventId}/reports") + public ResponseEntity getMemberBillReports(@PathVariable("eventId") String token) { + List memberBillReports = eventService.getMemberBillReports(token); + + return ResponseEntity.ok() + .body(MemberBillReportsResponse.of(memberBillReports)); + } + + @PostMapping("/api/events") + public ResponseEntity saveEvent(@Valid @RequestBody EventSaveRequest request) { + EventResponse eventResponse = EventResponse.of(eventService.saveEvent(request.toAppRequest())); + + String jwtToken = authService.createToken(eventResponse.eventId()); + + ResponseCookie responseCookie = createResponseCookie(jwtToken); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) + .body(eventResponse); + } + + @PostMapping("/api/events/{eventId}/login") + public ResponseEntity loginEvent( + @PathVariable("eventId") String token, + @Valid @RequestBody EventLoginRequest request + ) { + eventService.validatePassword(request.toAppRequest(token)); + String jwtToken = authService.createToken(token); + + ResponseCookie responseCookie = createResponseCookie(jwtToken); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) + .build(); + } + + private ResponseCookie createResponseCookie(String token) { + return ResponseCookie.from(authService.getTokenName(), token) + .httpOnly(cookieProperties.httpOnly()) + .secure(cookieProperties.secure()) + .domain(cookieProperties.domain()) + .path(cookieProperties.path()) + .sameSite(cookieProperties.sameSite()) + .maxAge(cookieProperties.maxAge()) + .build(); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/MemberController.java b/server/src/main/java/server/haengdong/presentation/MemberController.java new file mode 100644 index 000000000..f5662f9b2 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/MemberController.java @@ -0,0 +1,36 @@ +package server.haengdong.presentation; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import server.haengdong.application.MemberService; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.presentation.response.CurrentMembersResponse; +import server.haengdong.presentation.response.MembersResponse; + +@Slf4j +@RequiredArgsConstructor +@RestController +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/api/events/{eventId}/members") + public ResponseEntity findAllMembers(@PathVariable("eventId") String token) { + MembersResponse response = MembersResponse.of(memberService.findAllMembers(token)); + + return ResponseEntity.ok(response); + } + + @GetMapping("/api/events/{eventId}/members/current") + public ResponseEntity getCurrentMembers(@PathVariable("eventId") String token) { + List currentMembers = memberService.getCurrentMembers(token); + + return ResponseEntity.ok() + .body(CurrentMembersResponse.of(currentMembers)); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/admin/AdminBillController.java b/server/src/main/java/server/haengdong/presentation/admin/AdminBillController.java new file mode 100644 index 000000000..5e65c04f9 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/admin/AdminBillController.java @@ -0,0 +1,64 @@ +package server.haengdong.presentation.admin; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.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.RestController; +import server.haengdong.application.BillService; +import server.haengdong.presentation.request.BillDetailsUpdateRequest; +import server.haengdong.presentation.request.BillSaveRequest; +import server.haengdong.presentation.request.BillUpdateRequest; + +@RequiredArgsConstructor +@RestController +public class AdminBillController { + + private final BillService billService; + + @PostMapping("/api/admin/events/{eventId}/bills") + public ResponseEntity saveAllBill( + @PathVariable("eventId") String token, + @Valid @RequestBody BillSaveRequest request + ) { + billService.saveBill(token, request.toAppRequest()); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/api/admin/events/{eventId}/bills/{billId}") + public ResponseEntity deleteBill( + @PathVariable("eventId") String token, + @PathVariable("billId") Long billId + ) { + billService.deleteBill(token, billId); + + return ResponseEntity.ok().build(); + } + + @PutMapping("/api/admin/events/{eventId}/bills/{billId}") + public ResponseEntity updateBill( + @PathVariable("eventId") String token, + @PathVariable("billId") Long billId, + @Valid @RequestBody BillUpdateRequest request + ) { + billService.updateBill(token, billId, request.toAppResponse()); + + return ResponseEntity.ok().build(); + } + + @PutMapping("/api/admin/events/{eventId}/bills/{billId}/details") + public ResponseEntity updateBillDetails( + @PathVariable("eventId") String token, + @PathVariable("billId") Long billId, + @Valid @RequestBody BillDetailsUpdateRequest request + ) { + billService.updateBillDetails(token, billId, request.toAppRequest()); + + return ResponseEntity.ok().build(); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java b/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java new file mode 100644 index 000000000..46d11cdd5 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java @@ -0,0 +1,36 @@ +package server.haengdong.presentation.admin; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import server.haengdong.application.EventService; +import server.haengdong.presentation.request.EventUpdateRequest; + +@Slf4j +@RequiredArgsConstructor +@RestController +public class AdminEventController { + + private final EventService eventService; + + @PostMapping("/api/admin/events/{eventId}/auth") + public ResponseEntity authenticate() { + return ResponseEntity.ok().build(); + } + + @PatchMapping("/api/admin/events/{eventId}") + public ResponseEntity updateEvent( + @PathVariable("eventId") String token, + @Valid @RequestBody EventUpdateRequest request + ) { + eventService.updateEvent(token, request.toAppRequest()); + + return ResponseEntity.ok().build(); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/admin/AdminMemberController.java b/server/src/main/java/server/haengdong/presentation/admin/AdminMemberController.java new file mode 100644 index 000000000..22cb0190a --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/admin/AdminMemberController.java @@ -0,0 +1,55 @@ +package server.haengdong.presentation.admin; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RestController; +import server.haengdong.application.MemberService; +import server.haengdong.application.response.MembersSaveAppResponse; +import server.haengdong.presentation.request.MembersSaveRequest; +import server.haengdong.presentation.request.MembersUpdateRequest; +import server.haengdong.presentation.response.MembersSaveResponse; + +@Slf4j +@RequiredArgsConstructor +@RestController +public class AdminMemberController { + + private final MemberService memberService; + + @PostMapping("/api/admin/events/{eventId}/members") + public ResponseEntity saveMembers( + @PathVariable("eventId") String token, + @Valid @RequestBody MembersSaveRequest request + ) { + MembersSaveAppResponse response = memberService.saveMembers(token, request.toAppRequest()); + + return ResponseEntity.ok(MembersSaveResponse.of(response)); + } + + @PutMapping("/api/admin/events/{eventId}/members") + public ResponseEntity updateMembers( + @PathVariable("eventId") String token, + @Valid @RequestBody MembersUpdateRequest request + ) { + memberService.updateMembers(token, request.toAppRequest()); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/api/admin/events/{eventId}/members/{memberId}") + public ResponseEntity deleteMember( + @PathVariable("eventId") String token, + @PathVariable("memberId") Long memberId + ) { + memberService.deleteMember(token, memberId); + + return ResponseEntity.ok().build(); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java new file mode 100644 index 000000000..de670f28f --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/BillDetailUpdateRequest.java @@ -0,0 +1,19 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotNull; +import server.haengdong.application.request.BillDetailUpdateAppRequest; + +public record BillDetailUpdateRequest( + + @NotNull(message = "지출 상세 ID는 공백일 수 없습니다.") + Long id, + + @NotNull(message = "지출 금액은 공백일 수 없습니다.") + Long price, + + boolean isFixed +) { + public BillDetailUpdateAppRequest toAppRequest() { + return new BillDetailUpdateAppRequest(this.id, this.price, this.isFixed); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java new file mode 100644 index 000000000..f09d101bd --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/BillDetailsUpdateRequest.java @@ -0,0 +1,16 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import server.haengdong.application.request.BillDetailsUpdateAppRequest; + +public record BillDetailsUpdateRequest( + @Valid @NotEmpty List billDetails +) { + public BillDetailsUpdateAppRequest toAppRequest() { + return new BillDetailsUpdateAppRequest(billDetails.stream() + .map(BillDetailUpdateRequest::toAppRequest) + .toList()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/BillSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillSaveRequest.java new file mode 100644 index 000000000..1a7365a6e --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/BillSaveRequest.java @@ -0,0 +1,24 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import server.haengdong.application.request.BillAppRequest; + +public record BillSaveRequest( + + @NotBlank(message = "지출 내역 제목은 공백일 수 없습니다.") + String title, + + @NotNull(message = "지출 금액은 공백일 수 없습니다.") + Long price, + + @NotEmpty + List memberIds +) { + + public BillAppRequest toAppRequest() { + return new BillAppRequest(title, price, memberIds); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java new file mode 100644 index 000000000..a37c75013 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/BillUpdateRequest.java @@ -0,0 +1,18 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import server.haengdong.application.request.BillUpdateAppRequest; + +public record BillUpdateRequest( + + @NotBlank(message = "지출 내역 제목은 공백일 수 없습니다.") + String title, + + @NotNull(message = "지출 금액은 공백일 수 없습니다.") + Long price +) { + public BillUpdateAppRequest toAppResponse() { + return new BillUpdateAppRequest(title, price); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java b/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java new file mode 100644 index 000000000..a1286e903 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/EventLoginRequest.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import server.haengdong.application.request.EventLoginAppRequest; + +public record EventLoginRequest( + + @NotBlank(message = "비밀번호는 공백일 수 없습니다.") + String password +) { + public EventLoginAppRequest toAppRequest(String token) { + return new EventLoginAppRequest(token, password); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/EventSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/EventSaveRequest.java new file mode 100644 index 000000000..6bd7cd006 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/EventSaveRequest.java @@ -0,0 +1,18 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import server.haengdong.application.request.EventAppRequest; + +public record EventSaveRequest( + + @NotBlank(message = "행사 이름은 공백일 수 없습니다.") + String eventName, + + @NotBlank(message = "비밀번호는 공백일 수 없습니다.") + String password +) { + + public EventAppRequest toAppRequest() { + return new EventAppRequest(eventName, password); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/EventUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/EventUpdateRequest.java new file mode 100644 index 000000000..cafd12ea6 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/EventUpdateRequest.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.request; + +import server.haengdong.application.request.EventUpdateAppRequest; + +public record EventUpdateRequest( + String eventName, + String bankName, + String accountNumber +) { + + public EventUpdateAppRequest toAppRequest() { + return new EventUpdateAppRequest(eventName, bankName, accountNumber); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/MemberNameUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/MemberNameUpdateRequest.java new file mode 100644 index 000000000..fb1ff655f --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/MemberNameUpdateRequest.java @@ -0,0 +1,19 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import server.haengdong.application.request.MemberNameUpdateAppRequest; + +public record MemberNameUpdateRequest( + + @NotNull(message = "멤버 id는 공백일 수 없습니다.") + Long id, + + @NotBlank(message = "멤버 이름은 공백일 수 없습니다.") + String name +) { + + public MemberNameUpdateAppRequest toAppRequest() { + return new MemberNameUpdateAppRequest(id, name); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/MemberNamesUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/MemberNamesUpdateRequest.java new file mode 100644 index 000000000..79c46590d --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/MemberNamesUpdateRequest.java @@ -0,0 +1,20 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import server.haengdong.application.request.MemberNamesUpdateAppRequest; + +public record MemberNamesUpdateRequest( + @Valid + @NotEmpty + List members +) { + + public MemberNamesUpdateAppRequest toAppRequest() { + return new MemberNamesUpdateAppRequest(members.stream() + .map(MemberNameUpdateRequest::toAppRequest) + .toList() + ); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/MemberSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/MemberSaveRequest.java new file mode 100644 index 000000000..8b763be09 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/MemberSaveRequest.java @@ -0,0 +1,10 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; + +public record MemberSaveRequest( + + @NotBlank(message = "참여자 이름은 공백일 수 없습니다.") + String name +) { +} diff --git a/server/src/main/java/server/haengdong/presentation/request/MemberUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/MemberUpdateRequest.java new file mode 100644 index 000000000..1f3bfe95a --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/MemberUpdateRequest.java @@ -0,0 +1,22 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import server.haengdong.application.request.MemberUpdateAppRequest; + +public record MemberUpdateRequest( + + @NotNull(message = "멤버 ID는 공백일 수 없습니다.") + Long id, + + @NotBlank(message = "멤버 이름은 공백일 수 없습니다.") + String name, + + @NotNull(message = "입금 여부는 공백일 수 없습니다.") + boolean isDeposited +) { + + public MemberUpdateAppRequest toAppRequest() { + return new MemberUpdateAppRequest(id, name, isDeposited); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java new file mode 100644 index 000000000..c5e2100fd --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/MembersSaveRequest.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.request; + +import java.util.List; +import server.haengdong.application.request.MemberSaveAppRequest; +import server.haengdong.application.request.MembersSaveAppRequest; + +public record MembersSaveRequest(List members) { + public MembersSaveAppRequest toAppRequest() { + return new MembersSaveAppRequest(members.stream() + .map(member -> new MemberSaveAppRequest(member.name())) + .toList()); + + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/MembersUpdateRequest.java b/server/src/main/java/server/haengdong/presentation/request/MembersUpdateRequest.java new file mode 100644 index 000000000..db3715130 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/MembersUpdateRequest.java @@ -0,0 +1,20 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import server.haengdong.application.request.MembersUpdateAppRequest; + +public record MembersUpdateRequest( + + @Valid + @NotNull + List members +) { + + public MembersUpdateAppRequest toAppRequest() { + return new MembersUpdateAppRequest(members.stream() + .map(MemberUpdateRequest::toAppRequest) + .toList()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/BillDetailResponse.java b/server/src/main/java/server/haengdong/presentation/response/BillDetailResponse.java new file mode 100644 index 000000000..9ff993b8a --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/BillDetailResponse.java @@ -0,0 +1,20 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.BillDetailAppResponse; + +public record BillDetailResponse( + Long id, + String memberName, + Long price, + boolean isFixed +) { + + public static BillDetailResponse of(BillDetailAppResponse billDetailAppResponse) { + return new BillDetailResponse( + billDetailAppResponse.id(), + billDetailAppResponse.memberName(), + billDetailAppResponse.price(), + billDetailAppResponse.isFixed() + ); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/BillDetailsResponse.java b/server/src/main/java/server/haengdong/presentation/response/BillDetailsResponse.java new file mode 100644 index 000000000..888b0160d --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/BillDetailsResponse.java @@ -0,0 +1,16 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import java.util.stream.Collectors; +import server.haengdong.application.response.BillDetailsAppResponse; + +public record BillDetailsResponse( + List members +) { + + public static BillDetailsResponse of(BillDetailsAppResponse billDetailsAppResponse) { + return billDetailsAppResponse.billDetails().stream() + .map(BillDetailResponse::of) + .collect(Collectors.collectingAndThen(Collectors.toList(), BillDetailsResponse::new)); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/BillResponse.java b/server/src/main/java/server/haengdong/presentation/response/BillResponse.java new file mode 100644 index 000000000..8b79896bb --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/BillResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.BillAppResponse; + +public record BillResponse( + Long id, + String title, + Long price, + boolean isFixed +) { + public static BillResponse of(BillAppResponse response) { + return new BillResponse(response.id(), response.title(), response.price(), response.isFixed()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/CurrentMembersResponse.java b/server/src/main/java/server/haengdong/presentation/response/CurrentMembersResponse.java new file mode 100644 index 000000000..b1fe5720b --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/CurrentMembersResponse.java @@ -0,0 +1,16 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.LastBillMemberAppResponse; +import server.haengdong.application.response.MemberAppResponse; + +public record CurrentMembersResponse(List members) { + + public static CurrentMembersResponse of(List currentMembers) { + List responses = currentMembers.stream() + .map(MemberResponse::of) + .toList(); + + return new CurrentMembersResponse(responses); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/EventDetailResponse.java b/server/src/main/java/server/haengdong/presentation/response/EventDetailResponse.java new file mode 100644 index 000000000..e5421940c --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/EventDetailResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.EventDetailAppResponse; + +public record EventDetailResponse( + String eventName, + String bankName, + String accountNumber +) { + + public static EventDetailResponse of(EventDetailAppResponse response) { + return new EventDetailResponse(response.eventName(), response.bankName(), response.accountNumber()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/EventResponse.java b/server/src/main/java/server/haengdong/presentation/response/EventResponse.java new file mode 100644 index 000000000..506f5e814 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/EventResponse.java @@ -0,0 +1,10 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.EventAppResponse; + +public record EventResponse(String eventId) { + + public static EventResponse of(EventAppResponse eventAppResponse) { + return new EventResponse(eventAppResponse.token()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MemberBillReportResponse.java b/server/src/main/java/server/haengdong/presentation/response/MemberBillReportResponse.java new file mode 100644 index 000000000..1ab22b83e --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MemberBillReportResponse.java @@ -0,0 +1,20 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.MemberBillReportAppResponse; + +public record MemberBillReportResponse( + Long memberId, + String memberName, + boolean isDeposited, + Long price +) { + + public static MemberBillReportResponse of(MemberBillReportAppResponse memberBillReportAppResponse) { + return new MemberBillReportResponse( + memberBillReportAppResponse.memberId(), + memberBillReportAppResponse.name(), + memberBillReportAppResponse.isDeposited(), + memberBillReportAppResponse.price() + ); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MemberBillReportsResponse.java b/server/src/main/java/server/haengdong/presentation/response/MemberBillReportsResponse.java new file mode 100644 index 000000000..d350c4009 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MemberBillReportsResponse.java @@ -0,0 +1,15 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.MemberBillReportAppResponse; + +public record MemberBillReportsResponse(List reports) { + + public static MemberBillReportsResponse of(List memberBillReports) { + List reports = memberBillReports.stream() + .map(MemberBillReportResponse::of) + .toList(); + + return new MemberBillReportsResponse(reports); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MemberDepositResponse.java b/server/src/main/java/server/haengdong/presentation/response/MemberDepositResponse.java new file mode 100644 index 000000000..26699bc72 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MemberDepositResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.MemberDepositAppResponse; + +public record MemberDepositResponse( + Long id, + String name, + boolean isDeposited +) { + + public static MemberDepositResponse of(MemberDepositAppResponse response) { + return new MemberDepositResponse(response.id(), response.name(), response.isDeposited()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MemberResponse.java b/server/src/main/java/server/haengdong/presentation/response/MemberResponse.java new file mode 100644 index 000000000..6b43d72ee --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MemberResponse.java @@ -0,0 +1,18 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.LastBillMemberAppResponse; +import server.haengdong.application.response.MemberAppResponse; + +public record MemberResponse( + Long id, + String name +) { + + public static MemberResponse of(MemberAppResponse response) { + return new MemberResponse(response.id(), response.name()); + } + + public static MemberResponse of(LastBillMemberAppResponse response) { + return new MemberResponse(response.id(), response.name()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java b/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java new file mode 100644 index 000000000..b0740c6ca --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MemberSaveResponse.java @@ -0,0 +1,12 @@ +package server.haengdong.presentation.response; + +import server.haengdong.application.response.MemberSaveAppResponse; + +public record MemberSaveResponse( + Long id, + String name +) { + public static MemberSaveResponse of(MemberSaveAppResponse response) { + return new MemberSaveResponse(response.id(), response.name()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MembersResponse.java b/server/src/main/java/server/haengdong/presentation/response/MembersResponse.java new file mode 100644 index 000000000..333428761 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MembersResponse.java @@ -0,0 +1,16 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.MemberDepositAppResponse; +import server.haengdong.application.response.MembersDepositAppResponse; + +public record MembersResponse( + List members +) { + + public static MembersResponse of(MembersDepositAppResponse response) { + return new MembersResponse(response.members().stream() + .map(MemberDepositResponse::of) + .toList()); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java b/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java new file mode 100644 index 000000000..04e9b133e --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/MembersSaveResponse.java @@ -0,0 +1,16 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.MembersSaveAppResponse; + +public record MembersSaveResponse( + List members +) { + public static MembersSaveResponse of(MembersSaveAppResponse response) { + return new MembersSaveResponse( + response.members().stream() + .map(MemberSaveResponse::of) + .toList() + ); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/response/StepResponse.java b/server/src/main/java/server/haengdong/presentation/response/StepResponse.java new file mode 100644 index 000000000..924651c73 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/StepResponse.java @@ -0,0 +1,22 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.StepAppResponse; + +public record StepResponse( + List bills, + List members +) { + public static StepResponse of(StepAppResponse response) { + List bills = response.bills().stream() + .map(BillResponse::of) + .toList(); + + List members = response.members().stream() + .map(MemberResponse::of) + .toList(); + return new StepResponse(bills, members); + + } +} + diff --git a/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java b/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java new file mode 100644 index 000000000..baceeb549 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/response/StepsResponse.java @@ -0,0 +1,14 @@ +package server.haengdong.presentation.response; + +import java.util.List; +import server.haengdong.application.response.StepAppResponse; + +public record StepsResponse( + List steps +) { + public static StepsResponse of(List steps) { + return new StepsResponse(steps.stream() + .map(StepResponse::of) + .toList()); + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 000000000..22c337545 --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,67 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:database + username: sa + password: + + h2: + console: + enabled: true + path: /h2-console + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + default_batch_fetch_size: 30 + format_sql: true + jdbc.time_zone: Asia/Seoul + show-sql: true + +cors: + max-age: 3600 + allowed-origins: http://localhost:3000, https://haengdong.pro, https://dev.haengdong.pro, https://app.haengdong.pro + +security: + jwt: + token: + secret-key: skdmeejEKJdkDjklDlkj123DKLJ3kDkeDkDKQMEOD1D90D8dE + expire-length: 604800000 # 1주일 + +cookie: + http-only: false + secure: false + path: / + same-site: none + max-age: 7D + +management: + endpoints: + web: + exposure: + include: health, metrics, logfile + +server: + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true + +--- + +spring: + config: + import: classpath:config/application-prod.yml + activate: + on-profile: prod + +--- + +spring: + config: + import: classpath:config/application-dev.yml + activate: + on-profile: dev diff --git a/server/src/main/resources/config b/server/src/main/resources/config new file mode 160000 index 000000000..0650f4e19 --- /dev/null +++ b/server/src/main/resources/config @@ -0,0 +1 @@ +Subproject commit 0650f4e19e85907eeca5a5c16b8cbd7e146f43ab diff --git a/server/src/main/resources/logback-spring.xml b/server/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..283b966a3 --- /dev/null +++ b/server/src/main/resources/logback-spring.xml @@ -0,0 +1,97 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + ERROR + ACCEPT + DENY + + logs/spring-boot-application-error.log + + logs/spring-boot-application-error.%d{yyyy-MM-dd}.log + 30 + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + WARN + ACCEPT + DENY + + logs/spring-boot-application-warn.log + + logs/spring-boot-application-warn.%d{yyyy-MM-dd}.log + 30 + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + INFO + ACCEPT + DENY + + logs/spring-boot-application-info.log + + logs/spring-boot-application-info.%d{yyyy-MM-dd}.log + 30 + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + DEBUG + ACCEPT + DENY + + logs/spring-boot-application-debug.log + + logs/spring-boot-application-debug.%d{yyyy-MM-dd}.log + 30 + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/src/test/java/server/haengdong/application/BillServiceTest.java b/server/src/test/java/server/haengdong/application/BillServiceTest.java new file mode 100644 index 000000000..6d2b7aa8d --- /dev/null +++ b/server/src/test/java/server/haengdong/application/BillServiceTest.java @@ -0,0 +1,349 @@ +package server.haengdong.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import server.haengdong.application.request.BillAppRequest; +import server.haengdong.application.request.BillDetailUpdateAppRequest; +import server.haengdong.application.request.BillDetailsUpdateAppRequest; +import server.haengdong.application.request.BillUpdateAppRequest; +import server.haengdong.application.response.BillAppResponse; +import server.haengdong.application.response.BillDetailAppResponse; +import server.haengdong.application.response.BillDetailsAppResponse; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.StepAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.bill.BillDetail; +import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventRepository; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.member.MemberRepository; +import server.haengdong.exception.HaengdongException; +import server.haengdong.support.fixture.Fixture; + +class BillServiceTest extends ServiceTestSupport { + + @Autowired + private BillService billService; + + @Autowired + private EventRepository eventRepository; + + @Autowired + private BillRepository billRepository; + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("전체 지출 내역을 조회한다.") + @Test + void findSteps() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + Member member1 = new Member(event, "토다리"); + Member member2 = new Member(event, "쿠키"); + Member member3 = new Member(event, "소하"); + Member member4 = new Member(event, "웨디"); + memberRepository.saveAll(List.of(member1, member2, member3, member4)); + Bill bill1 = Bill.create(event, "행동대장 회식1", 100000L, List.of(member1, member2, member3)); + Bill bill2 = Bill.create(event, "행동대장 회식2", 200000L, List.of(member1, member2, member3, member4)); + Bill bill3 = Bill.create(event, "행동대장 회식3", 300000L, List.of(member1, member2, member3, member4)); + Bill bill4 = Bill.create(event, "행동대장 회식4", 400000L, List.of(member2, member3, member4)); + billRepository.saveAll(List.of(bill1, bill2, bill3, bill4)); + + List steps = billService.findSteps(event.getToken()); + + assertThat(steps).hasSize(3); + + assertThat(steps.get(0).bills()).hasSize(1) + .extracting(BillAppResponse::id, BillAppResponse::title, BillAppResponse::price, + BillAppResponse::isFixed) + .containsExactlyInAnyOrder( + tuple(bill1.getId(), bill1.getTitle(), bill1.getPrice(), bill1.isFixed()) + ); + + assertThat(steps.get(0).members()).hasSize(3) + .extracting(MemberAppResponse::id, MemberAppResponse::name) + .containsExactlyInAnyOrder( + tuple(member1.getId(), member1.getName()), + tuple(member2.getId(), member2.getName()), + tuple(member3.getId(), member3.getName()) + ); + + assertThat(steps.get(1).bills()).hasSize(2) + .extracting(BillAppResponse::id, BillAppResponse::title, BillAppResponse::price, + BillAppResponse::isFixed) + .containsExactlyInAnyOrder( + tuple(bill2.getId(), bill2.getTitle(), bill2.getPrice(), bill2.isFixed()), + tuple(bill3.getId(), bill3.getTitle(), bill3.getPrice(), bill3.isFixed()) + ); + + assertThat(steps.get(1).members()).hasSize(4) + .extracting(MemberAppResponse::id, MemberAppResponse::name) + .containsExactlyInAnyOrder( + tuple(member1.getId(), member1.getName()), + tuple(member2.getId(), member2.getName()), + tuple(member3.getId(), member3.getName()), + tuple(member4.getId(), member4.getName()) + ); + + assertThat(steps.get(2).bills()).hasSize(1) + .extracting(BillAppResponse::id, BillAppResponse::title, BillAppResponse::price, + BillAppResponse::isFixed) + .containsExactlyInAnyOrder( + tuple(bill4.getId(), bill4.getTitle(), bill4.getPrice(), bill4.isFixed()) + ); + + assertThat(steps.get(2).members()).hasSize(3) + .extracting(MemberAppResponse::id, MemberAppResponse::name) + .containsExactlyInAnyOrder( + tuple(member2.getId(), member2.getName()), + tuple(member3.getId(), member3.getName()), + tuple(member4.getId(), member4.getName()) + ); + } + + @DisplayName("지출 내역을 생성한다.") + @Test + void saveBill() { + Event event = Fixture.EVENT1; + Event savedEvent = eventRepository.save(event); + + Member member1 = Fixture.MEMBER1; + Member member2 = Fixture.MEMBER2; + memberRepository.saveAll(List.of(member1, member2)); + List memberIds = List.of(member1.getId(), member2.getId()); + BillAppRequest billAppRequest = new BillAppRequest("뽕족", 10_000L, memberIds); + + billService.saveBill(event.getToken(), billAppRequest); + + List bills = billRepository.findAllByEvent(savedEvent); + + assertThat(bills).extracting(Bill::getTitle, Bill::getPrice) + .containsExactlyInAnyOrder( + tuple("뽕족", 10_000L) + ); + } + + @DisplayName("지출 내역을 생성하면 지출 상세 내역이 생성된다.") + @Test + void saveBillTest1() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + + Member member1 = Fixture.MEMBER1; + Member member2 = Fixture.MEMBER2; + List members = List.of(member1, member2); + memberRepository.saveAll(members); + + BillAppRequest request = new BillAppRequest("뽕족", 10_000L, List.of(member1.getId(), member2.getId())); + + billService.saveBill(event.getToken(), request); + + List bills = billRepository.findAllByEvent(event); + + List billDetails = bills.get(0).getBillDetails(); + + assertThat(billDetails) + .hasSize(2) + .extracting("member", "price") + .containsExactlyInAnyOrder( + tuple(member1, 5_000L), + tuple(member2, 5_000L) + ); + } + + @DisplayName("토큰에 해당하는 이벤트가 존재하지 않으면 지출 내역을 생성할 수 없다.") + @Test + void saveBill1() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + + Member member1 = Fixture.MEMBER1; + Member member2 = Fixture.MEMBER2; + List members = List.of(member1, member2); + memberRepository.saveAll(members); + + BillAppRequest request = new BillAppRequest("뽕족", 10_000L, List.of(member1.getId(), member2.getId())); + + assertThatThrownBy(() -> billService.saveBill("wrongToken", request)) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("지출을 수정한다.") + @Test + void updateBill() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + + Bill bill = new Bill(event, "뽕족", 10_000L); + Bill savedBill = billRepository.save(bill); + + Long billId = savedBill.getId(); + BillUpdateAppRequest request = new BillUpdateAppRequest("인생맥주", 20_000L); + + billService.updateBill(event.getToken(), billId, request); + + Bill updatedBill = billRepository.findById(savedBill.getId()).get(); + + assertAll( + () -> assertThat(updatedBill.getTitle()).isEqualTo("인생맥주"), + () -> assertThat(updatedBill.getPrice()).isEqualTo(20_000L) + ); + } + + @DisplayName("행사에 속하지 않은 지출은 수정할 수 없다.") + @Test + void updateBill1() { + Event event1 = Fixture.EVENT1; + Event event2 = Fixture.EVENT2; + eventRepository.saveAll(List.of(event1, event2)); + Bill bill1 = new Bill(event1, "뽕족", 10_000L); + Bill bill2 = new Bill(event2, "뽕족", 10_000L); + Bill savedBill1 = billRepository.save(bill1); + billRepository.save(bill2); + + Long bill1Id = savedBill1.getId(); + BillUpdateAppRequest request = new BillUpdateAppRequest("인생맥주", 20_000L); + + assertThatThrownBy(() -> billService.updateBill(event2.getToken(), bill1Id, request)) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("지출 내역 금액을 변경하면 지출 디테일이 초기화 된다.") + @Test + void updateBill2() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + Member member1 = new Member(event, "감자"); + Member member2 = new Member(event, "고구마"); + Member member3 = new Member(event, "당근"); + Member member4 = new Member(event, "양파"); + List members = List.of(member1, member2, member3, member4); + memberRepository.saveAll(members); + Bill bill = Bill.create(event, "뽕족", 10_000L, members); + bill.getBillDetails().forEach(billDetail -> billDetail.updateIsFixed(true)); + billRepository.save(bill); + BillUpdateAppRequest request = new BillUpdateAppRequest("인생맥주", 20_000L); + + billService.updateBill(event.getToken(), bill.getId(), request); + + Bill updatedBill = billRepository.findAllByEvent(event).get(0); + List billDetails = updatedBill.getBillDetails(); + + assertThat(billDetails).hasSize(4) + .extracting(BillDetail::getPrice) + .containsExactly(5000L, 5000L, 5000L, 5000L); + } + + @DisplayName("지출 내역을 삭제한다.") + @Test + void deleteBill() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + Member member1 = new Member(event, "토다리"); + Member member2 = new Member(event, "쿠키"); + memberRepository.saveAll(List.of(member1, member2)); + + Bill bill = Bill.create(event, "뽕족", 10000L, List.of(member1, member2)); + billRepository.save(bill); + Long billId = bill.getId(); + + billService.deleteBill(event.getToken(), billId); + + assertThat(billRepository.findById(billId)).isEmpty(); + } + + @DisplayName("지출 내역 삭제 시 행사가 존재하지 않으면 예외가 발생한다.") + @Test + void deleteBill1() { + assertThatThrownBy(() -> billService.deleteBill("소하망쵸", 1L)) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("지출 금액 수정 요청의 총합이 지출 금액과 일치하지 않으면 예외가 발생한다.") + @Test + void updateBillDetailsTest1() { + Event event1 = Fixture.EVENT1; + eventRepository.save(event1); + Member member1 = new Member(event1, "토다리"); + Member member2 = new Member(event1, "쿠키"); + memberRepository.saveAll(List.of(member1, member2)); + + Bill bill = Bill.create(event1, "뽕족", 10000L, List.of(member1, member2)); + billRepository.save(bill); + List billDetails = bill.getBillDetails(); + + BillDetailsUpdateAppRequest request = new BillDetailsUpdateAppRequest(List.of( + new BillDetailUpdateAppRequest(billDetails.get(0).getId(), 3000L, true), + new BillDetailUpdateAppRequest(billDetails.get(1).getId(), 4000L, true) + )); + + assertThatThrownBy( + () -> billService.updateBillDetails(event1.getToken(), bill.getId(), request)) + .isInstanceOf(HaengdongException.class) + .hasMessage("지출 총액이 일치하지 않습니다."); + } + + @DisplayName("지출 고정 금액을 수정한다.") + @Test + void updateBillDetailsTest2() { + Event event1 = Fixture.EVENT1; + eventRepository.save(event1); + Member member1 = new Member(event1, "토다리"); + Member member2 = new Member(event1, "쿠키"); + memberRepository.saveAll(List.of(member1, member2)); + + Bill bill = Bill.create(event1, "뽕족", 10000L, List.of(member1, member2)); + billRepository.save(bill); + List billDetails = bill.getBillDetails(); + + BillDetailsUpdateAppRequest request = new BillDetailsUpdateAppRequest(List.of( + new BillDetailUpdateAppRequest(billDetails.get(0).getId(), 3000L, true), + new BillDetailUpdateAppRequest(billDetails.get(1).getId(), 7000L, true) + )); + + billService.updateBillDetails(event1.getToken(), bill.getId(), request); + + Bill foundBill = billRepository.findAllByEvent(event1).get(0); + List foundBillDetails = foundBill.getBillDetails(); + + assertThat(foundBillDetails).hasSize(2) + .extracting(BillDetail::getId, BillDetail::getPrice) + .containsExactly( + tuple(billDetails.get(0).getId(), 3000L), + tuple(billDetails.get(1).getId(), 7000L) + ); + } + + @DisplayName("참여자별 지출 금액을 조회한다.") + @Test + void findBillDetailsTest() { + Event event1 = Fixture.EVENT1; + eventRepository.save(event1); + + Member member1 = Fixture.MEMBER1; + Member member2 = Fixture.MEMBER2; + List members = List.of(member1, member2); + memberRepository.saveAll(members); + + Bill bill = Bill.create(event1, "뽕족", 10000L, members); + billRepository.save(bill); + + BillDetailsAppResponse response = billService.findBillDetails(event1.getToken(), bill.getId()); + + assertThat(response.billDetails()).hasSize(2) + .extracting(BillDetailAppResponse::memberName, BillDetailAppResponse::price) + .containsExactly( + tuple("토다리", 5000L), + tuple("쿠키", 5000L) + ); + } +} diff --git a/server/src/test/java/server/haengdong/application/EventServiceTest.java b/server/src/test/java/server/haengdong/application/EventServiceTest.java new file mode 100644 index 000000000..e40a682dc --- /dev/null +++ b/server/src/test/java/server/haengdong/application/EventServiceTest.java @@ -0,0 +1,165 @@ +package server.haengdong.application; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +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.mock.mockito.MockBean; +import server.haengdong.application.request.EventAppRequest; +import server.haengdong.application.request.EventUpdateAppRequest; +import server.haengdong.application.response.EventAppResponse; +import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.MemberBillReportAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.member.MemberRepository; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventRepository; +import server.haengdong.domain.event.EventTokenProvider; +import server.haengdong.support.fixture.Fixture; + +class EventServiceTest extends ServiceTestSupport { + + @Autowired + private EventService eventService; + + @Autowired + private EventRepository eventRepository; + + @Autowired + private BillRepository billRepository; + + @Autowired + private MemberRepository memberRepository; + + @MockBean + private EventTokenProvider eventTokenProvider; + + @DisplayName("행사를 생성한다") + @Test + void saveEventTest() { + EventAppRequest request = new EventAppRequest("test", "1234"); + given(eventTokenProvider.createToken()).willReturn("TOKEN"); + + EventAppResponse response = eventService.saveEvent(request); + + assertThat(response.token()).isEqualTo("TOKEN"); + } + + @DisplayName("토큰으로 행사를 조회한다.") + @Test + void findEventTest() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + + EventDetailAppResponse eventDetailAppResponse = eventService.findEvent(event.getToken()); + + assertThat(eventDetailAppResponse.eventName()).isEqualTo(event.getName()); + } + + @DisplayName("행사 정보를 수정한다.") + @Test + void updateEventTest() { + Event event = new Event("행동대장 비대위", "1234", "token"); + eventRepository.save(event); + + EventUpdateAppRequest eventUpdateAppRequest = new EventUpdateAppRequest("새로운 행사 이름", "토스뱅크", "12345678"); + eventService.updateEvent(event.getToken(), eventUpdateAppRequest); + + Event updateEvent = eventRepository.findByToken(event.getToken()).get(); + assertAll( + () -> assertThat(updateEvent.getName()).isEqualTo("새로운 행사 이름"), + () -> assertThat(updateEvent.getBankName()).isEqualTo("토스뱅크"), + () -> assertThat(updateEvent.getAccountNumber()).isEqualTo("12345678") + ); + } + + @DisplayName("행사의 은행 정보만 수정한다.") + @Test + void updateEventTest1() { + Event event = new Event("행동대장 비대위", "1234", "token"); + eventRepository.save(event); + + EventUpdateAppRequest eventUpdateAppRequest = new EventUpdateAppRequest(null, "토스뱅크", "12345678"); + eventService.updateEvent(event.getToken(), eventUpdateAppRequest); + + Event updateEvent = eventRepository.findByToken(event.getToken()).get(); + assertAll( + () -> assertThat(updateEvent.getName()).isEqualTo("행동대장 비대위"), + () -> assertThat(updateEvent.getBankName()).isEqualTo("토스뱅크"), + () -> assertThat(updateEvent.getAccountNumber()).isEqualTo("12345678") + ); + } + + @DisplayName("행사의 이름만 수정한다.") + @Test + void updateEventTest2() { + Event event = new Event("행동대장 비대위", "1234", "token"); + eventRepository.save(event); + + EventUpdateAppRequest eventUpdateAppRequest = new EventUpdateAppRequest("행동대장 정상 영업", null, null); + eventService.updateEvent(event.getToken(), eventUpdateAppRequest); + + Event updateEvent = eventRepository.findByToken(event.getToken()).get(); + assertAll( + () -> assertThat(updateEvent.getName()).isEqualTo("행동대장 정상 영업"), + () -> assertThat(updateEvent.getBankName()).isEqualTo(""), + () -> assertThat(updateEvent.getAccountNumber()).isEqualTo("") + ); + } + + @DisplayName("행사의 계좌 정보 일부가 누락되면 변경하지 않는다.") + @Test + void updateEventTest3() { + Event event = new Event("행동대장 비대위", "1234", "token"); + eventRepository.save(event); + + EventUpdateAppRequest eventUpdateAppRequest = new EventUpdateAppRequest(null, "망쵸뱅크", null); + eventService.updateEvent(event.getToken(), eventUpdateAppRequest); + + Event updateEvent = eventRepository.findByToken(event.getToken()).get(); + assertAll( + () -> assertThat(updateEvent.getName()).isEqualTo("행동대장 비대위"), + () -> assertThat(updateEvent.getBankName()).isEqualTo(""), + () -> assertThat(updateEvent.getAccountNumber()).isEqualTo("") + ); + } + + @DisplayName("참여자별 정산 현황을 조회한다.") + @Test + void getMemberBillReports() { + Event event = Fixture.EVENT1; + Event savedEvent = eventRepository.save(event); + List members = List.of( + new Member(savedEvent, "소하"), + new Member(savedEvent, "감자"), + new Member(savedEvent, "쿠키"), + new Member(savedEvent, "고구마") + ); + memberRepository.saveAll(members); + List bills = List.of( + Bill.create(savedEvent, "뽕족", 60_000L, members), + Bill.create(savedEvent, "인생네컷", 20_000L, members) + ); + billRepository.saveAll(bills); + + List responses = eventService.getMemberBillReports(event.getToken()); + + assertThat(responses) + .hasSize(4) + .extracting(MemberBillReportAppResponse::name, MemberBillReportAppResponse::price) + .containsExactlyInAnyOrder( + tuple("감자", 20_000L), + tuple("쿠키", 20_000L), + tuple("소하", 20_000L), + tuple("고구마", 20_000L) + ); + } +} diff --git a/server/src/test/java/server/haengdong/application/MemberServiceTest.java b/server/src/test/java/server/haengdong/application/MemberServiceTest.java new file mode 100644 index 000000000..662156a33 --- /dev/null +++ b/server/src/test/java/server/haengdong/application/MemberServiceTest.java @@ -0,0 +1,388 @@ +package server.haengdong.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static server.haengdong.support.fixture.Fixture.BILL1; +import static server.haengdong.support.fixture.Fixture.EVENT1; +import static server.haengdong.support.fixture.Fixture.EVENT2; +import static server.haengdong.support.fixture.Fixture.MEMBER1; +import static server.haengdong.support.fixture.Fixture.MEMBER2; +import static server.haengdong.support.fixture.Fixture.MEMBER3; + +import java.util.List; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import server.haengdong.application.request.MemberSaveAppRequest; +import server.haengdong.application.request.MemberUpdateAppRequest; +import server.haengdong.application.request.MembersSaveAppRequest; +import server.haengdong.application.request.MembersUpdateAppRequest; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.MemberDepositAppResponse; +import server.haengdong.application.response.MemberSaveAppResponse; +import server.haengdong.application.response.MembersDepositAppResponse; +import server.haengdong.application.response.MembersSaveAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.bill.BillDetail; +import server.haengdong.domain.bill.BillRepository; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.event.EventRepository; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.member.MemberRepository; +import server.haengdong.exception.HaengdongException; + +class MemberServiceTest extends ServiceTestSupport { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private EventRepository eventRepository; + + @Autowired + private BillRepository billRepository; + + @DisplayName("행사에 참여자를 추가한다.") + @Test + void saveMembersTest() { + Event event = EVENT1; + String memberName1 = "웨디"; + String memberName2 = "쿠키"; + Member member1 = new Member(event, memberName1); + Member member2 = new Member(event, memberName2); + eventRepository.save(event); + MembersSaveAppRequest request = new MembersSaveAppRequest( + List.of( + new MemberSaveAppRequest(member1.getName()), + new MemberSaveAppRequest(member2.getName()) + ) + ); + + MembersSaveAppResponse response = memberService.saveMembers(event.getToken(), request); + + List savedMembers = memberRepository.findAll(); + assertAll( + () -> assertThat(savedMembers) + .extracting(Member::getName) + .containsExactlyInAnyOrder(memberName1, memberName2), + () -> assertThat(response.members()) + .extracting(MemberSaveAppResponse::id, MemberSaveAppResponse::name) + .containsExactlyInAnyOrder( + Tuple.tuple(response.members().get(0).id(), memberName1), + Tuple.tuple(response.members().get(1).id(), memberName2) + ) + ); + } + + @DisplayName("행사에 존재하는 참여자를 추가하는 경우 예외가 발생한다.") + @Test + void saveMembersTest1() { + Event event = EVENT1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + eventRepository.save(event); + memberRepository.save(member1); + MembersSaveAppRequest request = new MembersSaveAppRequest( + List.of( + new MemberSaveAppRequest(member1.getName()), + new MemberSaveAppRequest(member2.getName()) + ) + ); + + assertThatThrownBy(() -> memberService.saveMembers(event.getToken(), request)) + .isInstanceOf(HaengdongException.class) + .hasMessage("현재 참여하고 있는 인원이 존재합니다."); + } + + @DisplayName("중복된 이름이 존재하는 경우 예외가 발생한다.") + @Test + void saveMembersTest2() { + Event event = EVENT1; + eventRepository.save(event); + MembersSaveAppRequest request = new MembersSaveAppRequest( + List.of( + new MemberSaveAppRequest("토다리"), + new MemberSaveAppRequest("토다리") + ) + ); + + assertThatThrownBy(() -> memberService.saveMembers(event.getToken(), request)) + .isInstanceOf(HaengdongException.class) + .hasMessageContaining("행사에 중복된 참여자 이름이 존재합니다."); + } + + @DisplayName("행사 참여 인원을 삭제한다.") + @Test + void deleteMemberTest() { + Event event = EVENT1; + Member member = MEMBER1; + eventRepository.save(event); + memberRepository.save(member); + + memberService.deleteMember(event.getToken(), member.getId()); + + assertThat(memberRepository.findById(member.getId())).isEmpty(); + } + + @DisplayName("다른 이벤트의 참여 인원을 삭제하는 경우 예외가 발생한다.") + @Test + void deleteMemberTest1() { + Event event1 = EVENT1; + Event event2 = EVENT2; + Member member = new Member(EVENT2, "감자"); + eventRepository.saveAll(List.of(event1, event2)); + memberRepository.save(member); + + assertThatThrownBy(() -> memberService.deleteMember(event1.getToken(), member.getId())) + .isInstanceOf(HaengdongException.class); + + assertThat(memberRepository.findById(member.getId())).isNotEmpty(); + } + + @DisplayName("행사 참여 인원을 삭제하는 경우 해당 참여자가 포함된 Bill이 초기화된다.") + @Test + void deleteMemberTest2() { + Event event1 = EVENT1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + List members = List.of(member1, member2); + Bill bill = Bill.create(event1, "title", 10000L, members); + eventRepository.save(event1); + memberRepository.saveAll(members); + + BillDetail billDetail1 = getDetailByMember(bill, member1); + BillDetail billDetail2 = getDetailByMember(bill, member2); + billDetail1.updatePrice(8000L); + billDetail1.updateIsFixed(false); + billDetail2.updatePrice(2000L); + billDetail2.updateIsFixed(true); + billRepository.save(bill); + + memberService.deleteMember(event1.getToken(), member1.getId()); + Bill bill1 = billRepository.findAllByEvent(event1).get(0); + List bill1Details = bill1.getBillDetails(); + + assertAll( + () -> assertThat(memberRepository.findById(member1.getId())).isEmpty(), + () -> assertThat(bill1Details).doesNotContain(billDetail1), + () -> { + BillDetail foundDetail = bill1Details.stream() + .filter(billDetail -> billDetail.isSameId(billDetail2.getId())).findFirst().get(); + assertThat(foundDetail.getPrice()).isEqualTo(10000L); + assertThat(foundDetail.isFixed()).isEqualTo(false); + } + ); + } + + private BillDetail getDetailByMember(Bill bill, Member member) { + return bill.getBillDetails() + .stream() + .filter(billDetail -> billDetail.isMember(member)) + .findFirst() + .orElseThrow(); + } + + @DisplayName("멤버 이름 정보를 수정한다.") + @Test + void updateMembersNameTest() { + Event event = EVENT1; + Member member = MEMBER1; + eventRepository.save(event); + memberRepository.save(member); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member.getId(), "수정된이름", true) + ) + ); + + memberService.updateMembers(event.getToken(), membersUpdateAppRequest); + + Member updatedMember = memberRepository.findById(member.getId()).orElseThrow(); + assertAll( + () -> assertThat(updatedMember.getName()).isEqualTo("수정된이름"), + () -> assertTrue(updatedMember.isDeposited()) + ); + } + + @DisplayName("멤버 정보를 수정한다.") + @Test + void updateMembersIsDepositedTest() { + Event event = EVENT1; + Member member = MEMBER1; + eventRepository.save(event); + memberRepository.save(member); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member.getId(), member.getName(), false) + ) + ); + + memberService.updateMembers(event.getToken(), membersUpdateAppRequest); + + Member updatedMember = memberRepository.findById(member.getId()).orElseThrow(); + assertAll( + () -> assertThat(updatedMember.getName()).isEqualTo(member.getName()), + () -> assertFalse(updatedMember.isDeposited()) + ); + } + + @DisplayName("수정할 멤버 id가 중복된 경우 예외가 발생한다.") + @Test + void updateMembersTest2() { + Event event = EVENT1; + Member member = MEMBER1; + eventRepository.save(event); + memberRepository.save(member); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member.getId(), "수정", true), + new MemberUpdateAppRequest(member.getId(), "수정수정", false) + ) + ); + + assertThatThrownBy(() -> memberService.updateMembers(event.getToken(), membersUpdateAppRequest)) + .isInstanceOf(HaengdongException.class) + .hasMessage("중복된 참여 인원 이름 변경 요청이 존재합니다."); + } + + @DisplayName("수정할 멤버 이름이 중복된 경우 예외가 발생한다.") + @Test + void updateMembersTest3() { + Event event = EVENT1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + eventRepository.save(event); + memberRepository.saveAll(List.of(member1, member2)); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member1.getId(), "수정", true), + new MemberUpdateAppRequest(member2.getId(), "수정", false) + ) + ); + + assertThatThrownBy(() -> memberService.updateMembers(event.getToken(), membersUpdateAppRequest)) + .isInstanceOf(HaengdongException.class) + .hasMessage("중복된 참여 인원 이름 변경 요청이 존재합니다."); + } + + @DisplayName("수정할 멤버가 행사에 존재하지 않는 경우 예외가 발생한다.") + @Test + void updateMembersTest4() { + Event event1 = EVENT1; + Event event2 = EVENT2; + Member member = new Member(event2, "이상"); + eventRepository.saveAll(List.of(event1, event2)); + memberRepository.save(member); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member.getId(), "수정", true) + ) + ); + + assertThatThrownBy(() -> memberService.updateMembers(event1.getToken(), membersUpdateAppRequest)) + .isInstanceOf(HaengdongException.class) + .hasMessage("업데이트 요청된 참여자 ID 목록과 기존 행사 참여자 ID 목록이 일치하지 않습니다."); + } + + @DisplayName("수정하려는 행사 참여 인원 이름이 이미 존재하는 경우 예외가 발생한다.") + @Test + void updateMembersTest5() { + Event event1 = EVENT1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + eventRepository.save(event1); + memberRepository.saveAll(List.of(member1, member2)); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member1.getId(), member2.getName(), true) + ) + ); + + assertThatThrownBy(() -> memberService.updateMembers(event1.getToken(), membersUpdateAppRequest)) + .isInstanceOf(HaengdongException.class) + .hasMessage("업데이트 요청된 참여자 ID 목록과 기존 행사 참여자 ID 목록이 일치하지 않습니다."); + } + + @DisplayName("참여자 간 서로의 이름으로 수정하려는 경우 예외가 발생한다.") + @Test + void updateMembersTest6() { + Event event = EVENT1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + eventRepository.save(event); + memberRepository.saveAll(List.of(member1, member2)); + MembersUpdateAppRequest membersUpdateAppRequest = new MembersUpdateAppRequest( + List.of( + new MemberUpdateAppRequest(member1.getId(), member2.getName(), true), + new MemberUpdateAppRequest(member2.getId(), member1.getName(), false) + ) + ); + + assertThatThrownBy(() -> memberService.updateMembers(event.getToken(), membersUpdateAppRequest)) + .isInstanceOf(HaengdongException.class) + .hasMessage("행사에 중복된 참여자 이름이 존재합니다."); + } + + @DisplayName("행사에 참여한 전체 인원을 조회한다.") + @Test + void findAllMembersTest() { + Event event = EVENT1; + Bill bill = BILL1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + Member member3 = MEMBER3; + eventRepository.save(event); + memberRepository.saveAll(List.of(member1, member2, member3)); + billRepository.save(bill); + + MembersDepositAppResponse membersDepositAppResponse = memberService.findAllMembers(event.getToken()); + + assertThat(membersDepositAppResponse.members()).hasSize(3) + .extracting(MemberDepositAppResponse::name, MemberDepositAppResponse::isDeposited) + .containsExactlyInAnyOrder( + tuple(member1.getName(), member1.isDeposited()), + tuple(member2.getName(), member2.isDeposited()), + tuple(member3.getName(), member3.isDeposited()) + ); + } + + @DisplayName("행사에 현재 참여 중인 인원을 조회한다.") + @Test + void getCurrentMembersTest() { + Event event = EVENT1; + Member member1 = MEMBER1; + Member member2 = MEMBER2; + Member member3 = MEMBER3; + Bill bill1 = Bill.create(event, "title1", 100000L, List.of(member1)); + Bill bill2 = Bill.create(event, "title2", 200000L, List.of(member1, member2, member3)); + Bill bill3 = Bill.create(event, "title2", 200000L, List.of(member1, member2, member3)); + eventRepository.save(event); + memberRepository.saveAll(List.of(member1, member2, member3)); + billRepository.saveAll(List.of(bill1, bill2, bill3)); + + List currentMembers = memberService.getCurrentMembers(event.getToken()); + + assertThat(currentMembers).hasSize(3) + .extracting(MemberAppResponse::id, MemberAppResponse::name) + .containsExactlyInAnyOrder( + tuple(member1.getId(), member1.getName()), + tuple(member2.getId(), member2.getName()), + tuple(member3.getId(), member3.getName()) + ); + } + + @DisplayName("행사가 없으면 현재 참여 인원을 조회할 수 없다.") + @Test + void getCurrentMembersTest1() { + assertThatThrownBy(() -> memberService.getCurrentMembers("token")) + .isInstanceOf(HaengdongException.class); + } +} diff --git a/server/src/test/java/server/haengdong/application/ServiceTestSupport.java b/server/src/test/java/server/haengdong/application/ServiceTestSupport.java new file mode 100644 index 000000000..008ba1035 --- /dev/null +++ b/server/src/test/java/server/haengdong/application/ServiceTestSupport.java @@ -0,0 +1,11 @@ +package server.haengdong.application; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import server.haengdong.support.extension.DatabaseCleanerExtension; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +abstract class ServiceTestSupport { +} diff --git a/server/src/test/java/server/haengdong/config/AdminInterceptorTest.java b/server/src/test/java/server/haengdong/config/AdminInterceptorTest.java new file mode 100644 index 000000000..f66da7fd1 --- /dev/null +++ b/server/src/test/java/server/haengdong/config/AdminInterceptorTest.java @@ -0,0 +1,56 @@ +package server.haengdong.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import server.haengdong.application.AuthService; +import server.haengdong.exception.AuthenticationException; +import server.haengdong.infrastructure.auth.AuthenticationExtractor; + +class AdminInterceptorTest { + + private AuthService authService; + private AuthenticationExtractor authenticationExtractor; + private AdminInterceptor adminInterceptor; + + @BeforeEach + public void setUp() { + authService = mock(AuthService.class); + authenticationExtractor = mock(AuthenticationExtractor.class); + adminInterceptor = new AdminInterceptor(authService, authenticationExtractor); + } + + @DisplayName("쿠키의 JWT 에서 eventToken 과 uri 의 eventToken 이 일치하면 관리자이다.") + @ParameterizedTest + @ValueSource(strings = {"/api/admin/events/12345", "/api/admin/events/12345/bills"}) + void validateToken1(String uri) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", uri); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(authService.findEventIdByToken(any())).thenReturn("12345"); + + boolean preHandle = adminInterceptor.preHandle(request, response, new Object()); + + assertThat(preHandle).isTrue(); + } + + @DisplayName("쿠키의 JWT 에서 eventToken 과 uri 의 eventToken 이 일치하지 않으면 거절당한다.") + @ParameterizedTest + @ValueSource(strings = {"/api/admin/events/12345", "/api/admin/events/12345/bills"}) + void validateToken2(String uri) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", uri); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(authService.findEventIdByToken(any())).thenReturn("125"); + + assertThatThrownBy(() -> adminInterceptor.preHandle(request, response, new Object())) + .isInstanceOf(AuthenticationException.class); + } +} diff --git a/server/src/test/java/server/haengdong/docs/AdminBillControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/AdminBillControllerDocsTest.java new file mode 100644 index 000000000..1c275801e --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/AdminBillControllerDocsTest.java @@ -0,0 +1,171 @@ +package server.haengdong.docs; + +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import server.haengdong.application.BillService; +import server.haengdong.presentation.admin.AdminBillController; +import server.haengdong.presentation.request.BillDetailUpdateRequest; +import server.haengdong.presentation.request.BillDetailsUpdateRequest; +import server.haengdong.presentation.request.BillSaveRequest; +import server.haengdong.presentation.request.BillUpdateRequest; +import server.haengdong.support.fixture.Fixture; + +class AdminBillControllerDocsTest extends RestDocsSupport { + + private final BillService billService = mock(BillService.class); + + @Override + protected Object initController() { + return new AdminBillController(billService); + } + + @DisplayName("지출 내역을 생성한다.") + @Test + void saveAllBill() throws Exception { + List members = List.of(1L, 2L); + BillSaveRequest request = new BillSaveRequest("뽕족", 10_000L, members); + + String requestBody = objectMapper.writeValueAsString(request); + String eventId = "쿠키토큰"; + + mockMvc.perform(post("/api/admin/events/{eventId}/bills", eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(Fixture.EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("createBills", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ), + requestFields( + fieldWithPath("title").description("생성할 지출 제목"), + fieldWithPath("price").description("생성할 지출 금액"), + fieldWithPath("memberIds").description("생성할 지출의 참여인원 ID 리스트") + ) + )); + } + + @DisplayName("지출을 수정한다.") + @Test + void updateBill() throws Exception { + BillUpdateRequest request = new BillUpdateRequest("뽕족", 10_000L); + + String requestBody = objectMapper.writeValueAsString(request); + String eventId = "웨디토큰"; + + mockMvc.perform(put("/api/admin/events/{eventId}/bills/{billId}", eventId, 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("updateBill", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID"), + parameterWithName("billId").description("지출 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ), + requestFields( + fieldWithPath("title").description("수정할 지출 제목"), + fieldWithPath("price").description("수정할 지출 금액") + ) + )); + } + + @DisplayName("참여자별 지출 금액을 수정한다.") + @Test + void updateBillDetailsTest() throws Exception { + List billDetailUpdateRequests = List.of( + new BillDetailUpdateRequest(1L, 10000L, true), + new BillDetailUpdateRequest(2L, 20000L, true) + ); + BillDetailsUpdateRequest request = new BillDetailsUpdateRequest( + billDetailUpdateRequests); + + String json = objectMapper.writeValueAsString(request); + + mockMvc.perform(put("/api/admin/events/{eventId}/bills/{billId}/details", "TOKEN", 1L) + .cookie(EVENT_COOKIE) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("updateBillDetails", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID"), + parameterWithName("billId").description("지출 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ), + requestFields( + fieldWithPath("billDetails").type(JsonFieldType.ARRAY) + .description("전체 정산 수정 요청 목록"), + fieldWithPath("billDetails[0].id").type(JsonFieldType.NUMBER) + .description("참여자 이름"), + fieldWithPath("billDetails[0].price").type(JsonFieldType.NUMBER) + .description("참여자 정산 금액"), + fieldWithPath("billDetails[0].isFixed").type(JsonFieldType.BOOLEAN) + .description("참여자 정산 금액 수정 여부") + ) + ) + ); + } + + @DisplayName("지출 내역을 삭제한다.") + @Test + void deleteBill() throws Exception { + String eventId = "토다리토큰"; + + mockMvc.perform(delete("/api/admin/events/{eventId}/bills/{billId}", eventId, 1) + .cookie(EVENT_COOKIE) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("deleteBill", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID"), + parameterWithName("billId").description("지출 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ) + )); + } +} diff --git a/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java new file mode 100644 index 000000000..22c028169 --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java @@ -0,0 +1,95 @@ +package server.haengdong.docs; + +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import server.haengdong.application.EventService; +import server.haengdong.presentation.admin.AdminEventController; +import server.haengdong.presentation.request.EventUpdateRequest; + +class AdminEventControllerDocsTest extends RestDocsSupport { + + private final EventService eventService = mock(EventService.class); + + @Override + protected Object initController() { + return new AdminEventController(eventService); + } + + @DisplayName("행사 어드민 권한을 확인한다.") + @Test + void authenticateTest() throws Exception { + String token = "TOKEN"; + mockMvc.perform(post("/api/admin/events/{eventId}/auth", token) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + + .andDo( + document("authenticateEvent", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰").optional() + ) + ) + ); + } + + @DisplayName("행사 정보를 업데이트한다.") + @Test + void updateEventTest() throws Exception { + String token = "TOKEN"; + EventUpdateRequest eventUpdateRequest = new EventUpdateRequest("행동대장 비대위", "행대뱅크", "12345678"); + + String requestBody = objectMapper.writeValueAsString(eventUpdateRequest); + + mockMvc.perform(patch("/api/admin/events/{eventId}", token) + .cookie(EVENT_COOKIE) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("updateEvent", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 관리자 토큰") + ), + requestFields( + fieldWithPath("eventName").type(JsonFieldType.STRING) + .description("수정할 이벤트 이름").optional(), + fieldWithPath("bankName").type(JsonFieldType.STRING) + .description("은행 이름").optional(), + fieldWithPath("accountNumber").type(JsonFieldType.STRING) + .description("계좌 번호").optional() + ) + ) + ); + } +} diff --git a/server/src/test/java/server/haengdong/docs/AdminMemberControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/AdminMemberControllerDocsTest.java new file mode 100644 index 000000000..9d70673f9 --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/AdminMemberControllerDocsTest.java @@ -0,0 +1,173 @@ +package server.haengdong.docs; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import server.haengdong.application.MemberService; +import server.haengdong.application.response.MemberSaveAppResponse; +import server.haengdong.application.response.MembersSaveAppResponse; +import server.haengdong.presentation.admin.AdminMemberController; +import server.haengdong.presentation.request.MemberSaveRequest; +import server.haengdong.presentation.request.MemberUpdateRequest; +import server.haengdong.presentation.request.MembersSaveRequest; +import server.haengdong.presentation.request.MembersUpdateRequest; + +class AdminMemberControllerDocsTest extends RestDocsSupport { + + private final MemberService memberService = mock(MemberService.class); + + @Override + protected Object initController() { + return new AdminMemberController(memberService); + } + + @DisplayName("행사 참여자를 추가한다.") + @Test + void saveMemberTest() throws Exception { + String eventToken = "망쵸토큰"; + MembersSaveRequest membersSaveRequest = new MembersSaveRequest( + List.of( + new MemberSaveRequest("웨디"), + new MemberSaveRequest("소하") + ) + ); + String requestBody = objectMapper.writeValueAsString(membersSaveRequest); + MembersSaveAppResponse appResponse = new MembersSaveAppResponse( + List.of( + new MemberSaveAppResponse(1L, "웨디"), + new MemberSaveAppResponse(2L, "소하") + ) + ); + given(memberService.saveMembers(eventToken, membersSaveRequest.toAppRequest())) + .willReturn(appResponse); + + mockMvc.perform(post("/api/admin/events/{eventId}/members", "망쵸토큰") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[0].id").value(equalTo(1))) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[0].name").value(equalTo("웨디"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[1].id").value(equalTo(2))) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[1].name").value(equalTo("소하"))) + .andDo( + document("saveMembers", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestCookies( + cookieWithName("eventToken").description("토큰 토큰") + ), + requestFields( + fieldWithPath("members").type(JsonFieldType.ARRAY) + .description("행사에 추가할 참여자 목록"), + fieldWithPath("members[].name").type(JsonFieldType.STRING) + .description("참여자 이름") + ), + responseFields( + fieldWithPath("members").type(JsonFieldType.ARRAY) + .description("행사에 추가된 참여자 목록"), + fieldWithPath("members[].id").type(JsonFieldType.NUMBER) + .description("참여자 ID"), + fieldWithPath("members[].name").type(JsonFieldType.STRING) + .description("참여자 이름") + ) + ) + ); + } + + @DisplayName("행사 참여 인원을 삭제한다.") + @Test + void deleteMember() throws Exception { + String eventId = "망쵸토큰"; + Long memberId = 1L; + + mockMvc.perform(delete("/api/admin/events/{eventId}/members/{memberId}", eventId, memberId) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("deleteMember", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID"), + parameterWithName("memberId").description("삭제할 참여자 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 토큰") + ) + ) + ); + } + + @DisplayName("행사 참여 인원 정보를 수정한다.") + @Test + void updateMembers() throws Exception { + String eventId = "망쵸토큰"; + MembersUpdateRequest membersUpdateRequest = new MembersUpdateRequest( + List.of( + new MemberUpdateRequest(1L, "토다리", true), + new MemberUpdateRequest(2L, "쿠키", false) + ) + ); + String requestBody = objectMapper.writeValueAsString(membersUpdateRequest); + + mockMvc.perform(put("/api/admin/events/{eventId}/members", eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("updateMembers", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestCookies( + cookieWithName("eventToken").description("행사 토큰") + ), + requestFields( + fieldWithPath("members").type(JsonFieldType.ARRAY) + .description("수정할 참여자 목록"), + fieldWithPath("members[].id").type(JsonFieldType.NUMBER) + .description("참여자 ID"), + fieldWithPath("members[].name").type(JsonFieldType.STRING) + .description("참여자 이름"), + fieldWithPath("members[].isDeposited").type(JsonFieldType.BOOLEAN) + .description("참여자 입금 여부") + ) + ) + ); + } +} diff --git a/server/src/test/java/server/haengdong/docs/BillControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/BillControllerDocsTest.java new file mode 100644 index 000000000..0716a2123 --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/BillControllerDocsTest.java @@ -0,0 +1,119 @@ +package server.haengdong.docs; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import server.haengdong.application.BillService; +import server.haengdong.application.response.BillAppResponse; +import server.haengdong.application.response.BillDetailAppResponse; +import server.haengdong.application.response.BillDetailsAppResponse; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.StepAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; +import server.haengdong.presentation.BillController; +import server.haengdong.support.fixture.Fixture; + +class BillControllerDocsTest extends RestDocsSupport { + + private final BillService billService = mock(BillService.class); + + @Override + protected Object initController() { + return new BillController(billService); + } + + @DisplayName("전체 지출 내역을 조회한다.") + @Test + void findBills() throws Exception { + Bill bill = Fixture.BILL1; + List bills = List.of(BillAppResponse.of(bill)); + + Member member = Fixture.MEMBER1; + List members = List.of(MemberAppResponse.of(member)); + + StepAppResponse stepAppResponse = new StepAppResponse(bills, members); + given(billService.findSteps(anyString())).willReturn(List.of(stepAppResponse)); + + mockMvc.perform(get("/api/events/{eventId}/bills", "token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.steps").isArray()) + .andExpect(jsonPath("$.steps[0].bills").isArray()) + .andExpect(jsonPath("$.steps[0].bills[0].id").value(bill.getId())) + .andExpect(jsonPath("$.steps[0].bills[0].title").value(bill.getTitle())) + .andExpect(jsonPath("$.steps[0].bills[0].price").value(bill.getPrice())) + .andExpect(jsonPath("$.steps[0].bills[0].isFixed").value(bill.isFixed())) + .andExpect(jsonPath("$.steps[0].members").isArray()) + .andExpect(jsonPath("$.steps[0].members[0].id").value(member.getId())) + .andExpect(jsonPath("$.steps[0].members[0].name").value(member.getName())) + .andDo(document("findBills", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + responseFields( + fieldWithPath("steps").description("지출 차수 목록"), + fieldWithPath("steps[].bills").description("해당 차수의 지출 내역"), + fieldWithPath("steps[].bills[].id").description("지출 ID"), + fieldWithPath("steps[].bills[].title").description("지출 이름"), + fieldWithPath("steps[].bills[].price").description("지출 금액"), + fieldWithPath("steps[].bills[].isFixed").description("지출 수정 여부"), + fieldWithPath("steps[].members").description("해당 차수의 참여자 목록"), + fieldWithPath("steps[].members[].id").description("참여자 ID"), + fieldWithPath("steps[].members[].name").description("참여자 이름") + ) + ) + ); + } + + + @DisplayName("참여자별 지출 금액을 조회한다.") + @Test + void findBillDetails() throws Exception { + BillDetailsAppResponse appResponse = new BillDetailsAppResponse( + List.of(new BillDetailAppResponse(1L, "토다리", 1000L, false))); + given(billService.findBillDetails(anyString(), anyLong())).willReturn(appResponse); + + mockMvc.perform(get("/api/events/{eventId}/bills/{billId}/details", "TOKEN", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(1L)) + .andExpect(jsonPath("$.members[0].memberName").value("토다리")) + .andExpect(jsonPath("$.members[0].price").value(1000L)) + .andExpect(jsonPath("$.members[0].isFixed").value(false)) + .andDo(document("findBillDetails", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID"), + parameterWithName("billId").description("지출 ID") + ), responseFields( + fieldWithPath("members").description("참여자 목록"), + fieldWithPath("members[].id").description("참여자 ID"), + fieldWithPath("members[].memberName").description("참여자 이름"), + fieldWithPath("members[].price").description("참여자별 지출 금액"), + fieldWithPath("members[].isFixed").description("지출 수정 여부") + ) + ) + ); + } +} diff --git a/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java new file mode 100644 index 000000000..83144f135 --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/EventControllerDocsTest.java @@ -0,0 +1,195 @@ +package server.haengdong.docs; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import server.haengdong.application.AuthService; +import server.haengdong.application.EventService; +import server.haengdong.application.request.EventAppRequest; +import server.haengdong.application.response.EventAppResponse; +import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.MemberBillReportAppResponse; +import server.haengdong.infrastructure.auth.CookieProperties; +import server.haengdong.presentation.EventController; +import server.haengdong.presentation.request.EventLoginRequest; +import server.haengdong.presentation.request.EventSaveRequest; + +class EventControllerDocsTest extends RestDocsSupport { + + private final EventService eventService = mock(EventService.class); + private final AuthService authService = mock(AuthService.class); + private final CookieProperties cookieProperties = new CookieProperties( + true, true, "domain", "path", "none", Duration.ofDays(7) + ); + + @Override + protected Object initController() { + return new EventController(eventService, authService, cookieProperties); + } + + @DisplayName("토큰으로 행사를 조회한다.") + @Test + void findEventTest() throws Exception { + String eventId = "망쵸토큰"; + EventDetailAppResponse eventDetailAppResponse = new EventDetailAppResponse("행동대장 회식", "토스뱅크", "12312455"); + given(eventService.findEvent(eventId)).willReturn(eventDetailAppResponse); + + mockMvc.perform(get("/api/events/{eventId}", eventId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.eventName").value("행동대장 회식")) + .andDo( + document("getEvent", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + responseFields( + fieldWithPath("eventName").type(JsonFieldType.STRING).description("행사 이름"), + fieldWithPath("bankName").type(JsonFieldType.STRING).description("토스뱅크"), + fieldWithPath("accountNumber").type(JsonFieldType.STRING).description("12312455") + ) + ) + ); + } + + @DisplayName("참여자별 정산 현황을 조회한다.") + @Test + void getMemberBillReports() throws Exception { + List memberBillReportAppResponses = List.of( + new MemberBillReportAppResponse(1L, "소하", false, 20_000L), + new MemberBillReportAppResponse(2L, "토다리", false, 200_000L) + ); + + given(eventService.getMemberBillReports(any())).willReturn(memberBillReportAppResponses); + + mockMvc.perform(get("/api/events/{eventId}/reports", "망쵸토큰") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].memberId").value(equalTo(1))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].memberName").value(equalTo("소하"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].isDeposited").value(equalTo(false))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].price").value(equalTo(20_000))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].memberId").value(equalTo(2))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].memberName").value(equalTo("토다리"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].isDeposited").value(equalTo(false))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].price").value(equalTo(200_000))) + .andDo( + document("getMemberBillReports", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + responseFields( + fieldWithPath("reports").type(JsonFieldType.ARRAY).description("전체 정산 현황 목록"), + fieldWithPath("reports[0].memberId").type(JsonFieldType.NUMBER) + .description("참여자 ID"), + fieldWithPath("reports[0].memberName").type(JsonFieldType.STRING) + .description("참여자 이름"), + fieldWithPath("reports[0].isDeposited").type(JsonFieldType.BOOLEAN) + .description("참여자 이름"), + fieldWithPath("reports[0].price").type(JsonFieldType.NUMBER) + .description("참여자 정산 금액") + )) + ); + } + + @DisplayName("이벤트를 생성한다.") + @Test + void saveEvent() throws Exception { + EventSaveRequest eventSaveRequest = new EventSaveRequest("토다리", "0987"); + String requestBody = objectMapper.writeValueAsString(eventSaveRequest); + String eventId = "쿠키 토큰"; + EventAppResponse eventAppResponse = new EventAppResponse(eventId); + given(eventService.saveEvent(any(EventAppRequest.class))).willReturn(eventAppResponse); + given(authService.createToken(eventId)).willReturn("jwtToken"); + given(authService.getTokenName()).willReturn("eventToken"); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(cookie().value("eventToken", "jwtToken")) + .andExpect(jsonPath("$.eventId").value("쿠키 토큰")) + .andDo( + document("createEvent", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("eventName").type(JsonFieldType.STRING).description("행사 이름"), + fieldWithPath("password").type(JsonFieldType.STRING).description("행사 비밀 번호") + ), + responseFields( + fieldWithPath("eventId").type(JsonFieldType.STRING) + .description("행사 ID") + ), + responseCookies( + cookieWithName("eventToken").description("행사 관리자용 토큰") + ) + ) + ); + } + + @DisplayName("행사 어드민이 로그인한다.") + @Test + void loginEvent() throws Exception { + String token = "TOKEN"; + EventLoginRequest eventLoginRequest = new EventLoginRequest("1234"); + String requestBody = objectMapper.writeValueAsString(eventLoginRequest); + given(authService.createToken(token)).willReturn("jwtToken"); + given(authService.getTokenName()).willReturn("eventToken"); + + mockMvc.perform(post("/api/events/{eventId}/login", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(cookie().value("eventToken", "jwtToken")) + .andExpect(status().isOk()) + .andDo( + document("eventLogin", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + requestFields( + fieldWithPath("password").type(JsonFieldType.STRING) + .description("행사 비밀 번호") + ), + responseCookies( + cookieWithName("eventToken").description("행사 관리자용 토큰") + ) + ) + ); + } +} diff --git a/server/src/test/java/server/haengdong/docs/MemberControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/MemberControllerDocsTest.java new file mode 100644 index 000000000..2997ad4ee --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/MemberControllerDocsTest.java @@ -0,0 +1,120 @@ +package server.haengdong.docs; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import server.haengdong.application.MemberService; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.MemberDepositAppResponse; +import server.haengdong.application.response.MembersDepositAppResponse; +import server.haengdong.presentation.MemberController; + +class MemberControllerDocsTest extends RestDocsSupport { + + private final MemberService memberService = mock(MemberService.class); + + @Override + protected Object initController() { + return new MemberController(memberService); + } + + @DisplayName("행사에 참여한 전체 인원을 조회한다.") + @Test + void findAllMembersTest() throws Exception { + List members = List.of( + new MemberDepositAppResponse(1L, "감자", false), + new MemberDepositAppResponse(2L, "백호", true), + new MemberDepositAppResponse(3L, "이상", true) + ); + + MembersDepositAppResponse memberAppResponse = new MembersDepositAppResponse(members); + given(memberService.findAllMembers(anyString())).willReturn(memberAppResponse); + + mockMvc.perform(get("/api/events/{eventId}/members", "TOKEN")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(1L)) + .andExpect(jsonPath("$.members[0].name").value("감자")) + .andExpect(jsonPath("$.members[0].isDeposited").value(false)) + .andExpect(jsonPath("$.members[1].id").value(2L)) + .andExpect(jsonPath("$.members[1].name").value("백호")) + .andExpect(jsonPath("$.members[1].isDeposited").value(true)) + .andExpect(jsonPath("$.members[2].id").value(3L)) + .andExpect(jsonPath("$.members[2].name").value("이상")) + .andExpect(jsonPath("$.members[2].isDeposited").value(true)) + .andDo( + document("findAllMembers", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + responseFields( + fieldWithPath("members").type(JsonFieldType.ARRAY) + .description("행사에 참여 중인 전체 멤버 목록"), + fieldWithPath("members[0].id").type(JsonFieldType.NUMBER) + .description("멤버 ID"), + fieldWithPath("members[0].name").type(JsonFieldType.STRING) + .description("멤버 이름"), + fieldWithPath("members[0].isDeposited").type(JsonFieldType.BOOLEAN) + .description("입금 여부") + ) + ) + ); + } + + @DisplayName("현재 참여 인원을 조회합니다.") + @Test + void getCurrentMembers() throws Exception { + List members = List.of( + new MemberAppResponse(1L, "감자"), + new MemberAppResponse(2L, "백호") + ); + + given(memberService.getCurrentMembers(any())).willReturn(members); + + mockMvc.perform(get("/api/events/{eventId}/members/current", "TOKEN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(1L)) + .andExpect(jsonPath("$.members[0].name").value("감자")) + .andExpect(jsonPath("$.members[1].id").value(2L)) + .andExpect(jsonPath("$.members[1].name").value("백호")) + .andDo( + document("getCurrentMembers", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("eventId").description("행사 ID") + ), + responseFields( + fieldWithPath("members").type(JsonFieldType.ARRAY) + .description("현재 행사에 참여 중인 멤버 목록"), + fieldWithPath("members[0].id").type(JsonFieldType.NUMBER) + .description("멤버 ID"), + fieldWithPath("members[0].name").type(JsonFieldType.STRING) + .description("멤버 이름") + ) + ) + ); + } +} diff --git a/server/src/test/java/server/haengdong/docs/RestDocsSupport.java b/server/src/test/java/server/haengdong/docs/RestDocsSupport.java new file mode 100644 index 000000000..3e6901ba8 --- /dev/null +++ b/server/src/test/java/server/haengdong/docs/RestDocsSupport.java @@ -0,0 +1,28 @@ +package server.haengdong.docs; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith({RestDocumentationExtension.class}) +abstract class RestDocsSupport { + + protected MockMvc mockMvc; + + protected ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) + .apply(documentationConfiguration(restDocumentation)) + .build(); + } + + protected abstract Object initController(); +} diff --git a/server/src/test/java/server/haengdong/domain/bill/BillTest.java b/server/src/test/java/server/haengdong/domain/bill/BillTest.java new file mode 100644 index 000000000..d1c2e3d8c --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/bill/BillTest.java @@ -0,0 +1,98 @@ +package server.haengdong.domain.bill; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static server.haengdong.support.fixture.Fixture.EVENT1; + +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 server.haengdong.domain.member.Member; +import server.haengdong.exception.HaengdongException; + +class BillTest { + + @DisplayName("지출 내역 제목의 앞뒤 공백을 제거한 길이가 1 ~ 30자가 아니면 지출을 생성할 수 없다.") + @ParameterizedTest + @ValueSource(strings = {"", " ", "1234567890123456789012345678901"}) + void validateTitle(String title) { + Long price = 100L; + + assertThatThrownBy(() -> new Bill(EVENT1, title, price)) + .isInstanceOf(HaengdongException.class) + .hasMessage("앞뒤 공백을 제거한 지출 내역 제목은 1 ~ 30자여야 합니다."); + } + + @DisplayName("금액이 10,000,000 이하의 자연수가 아니면 지출을 생성할 수 없다.") + @ParameterizedTest + @ValueSource(longs = {0, 10_000_001, 20_000_000}) + void validatePrice(long price) { + String title = "title"; + + assertThatThrownBy(() -> new Bill(EVENT1, title, price)) + .isInstanceOf(HaengdongException.class) + .hasMessage("지출 금액은 10,000,000 이하의 자연수여야 합니다."); + } + + @DisplayName("지출 내역을 올바르게 생성한다.") + @Test + void createBill() { + String title = "title"; + Long price = 1_000L; + + Bill bill = new Bill(EVENT1, title, price); + + assertAll( + () -> assertThat(bill.getTitle()).isEqualTo(title), + () -> assertThat(bill.getPrice()).isEqualTo(price) + ); + } + + @DisplayName("지출에 멤버별 고정 금액이 설정되어 있는지 확인한다.") + @Test + void isFixed1() { + List members = List.of(new Member(EVENT1, "감자"), new Member(EVENT1, "고구마")); + Bill fixedBill = Bill.create(EVENT1, "인생네컷", 2_000L, members); + + assertThat(fixedBill.isFixed()).isEqualTo(false); + } + + @DisplayName("같은 멤버 목록을 가지고 있는지 비교한다.") + @Test + void isSameMember1() { + Member member1 = new Member(1L, EVENT1, "감자", false); + Member member2 = new Member(2L, EVENT1, "고구마", false); + Member member3 = new Member(3L, EVENT1, "당근", false); + + List members1 = List.of(member1, member2, member3); + List members2 = List.of(member2, member3, member1); + + Bill bill1 = Bill.create(EVENT1, "뽕족", 20_000L, members1); + Bill bill2 = Bill.create(EVENT1, "인생네컷", 30_000L, members2); + + boolean isSameMembers = bill1.isSameMembers(bill2); + + assertThat(isSameMembers).isTrue(); + } + + @DisplayName("같은 멤버 목록을 가지고 있는지 비교한다.") + @Test + void isSameMember2() { + Member member1 = new Member(1L, EVENT1, "감자", false); + Member member2 = new Member(2L, EVENT1, "고구마", false); + Member member3 = new Member(3L, EVENT1, "당근", false); + + List members1 = List.of(member1, member2, member3); + List members2 = List.of(member2, member1); + + Bill bill1 = Bill.create(EVENT1, "뽕족", 20_000L, members1); + Bill bill2 = Bill.create(EVENT1, "인생네컷", 30_000L, members2); + + boolean isSameMembers = bill1.isSameMembers(bill2); + + assertThat(isSameMembers).isFalse(); + } +} diff --git a/server/src/test/java/server/haengdong/domain/event/EventTest.java b/server/src/test/java/server/haengdong/domain/event/EventTest.java new file mode 100644 index 000000000..67c5420b0 --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/event/EventTest.java @@ -0,0 +1,165 @@ +package server.haengdong.domain.event; + +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 static org.junit.jupiter.api.Assertions.assertAll; + +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 server.haengdong.exception.HaengdongException; + +class EventTest { + + @DisplayName("공백 문자가 연속되지 않고, 이름이 2자 이상 20자 이하인 행사를 생성하면 예외가 발생하지 않는다.") + @ParameterizedTest + @ValueSource(strings = {"12", "12345678901234567890", "공 백", " 공백", "공백 ", " 공 백 "}) + void createSuccessTest(String eventName) { + assertThatCode(() -> new Event(eventName, "1234", "TEST_TOKEN")) + .doesNotThrowAnyException(); + } + + @DisplayName("공백 문자가 연속되면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {" 공백", "공백 ", "공백 연속", "공 백"}) + void createFailTest1(String eventName) { + assertThatCode(() -> new Event(eventName, "1234", "TEST_TOKEN")) + .isInstanceOf(HaengdongException.class) + .hasMessage("행사 이름에는 공백 문자가 연속될 수 없습니다."); + } + + @DisplayName("이름이 1자 미만이거나 20자 초과인 경우 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"", " ", "123456789012345678901"}) + void createFilTest2(String eventName) { + assertThatCode(() -> new Event(eventName, "1234", "TEST_TOKEN")) + .isInstanceOf(HaengdongException.class) + .hasMessage("행사 이름은 1자 이상 20자 이하만 입력 가능합니다."); + } + + @DisplayName("비밀번호는 4자리 숫자 입니다.") + @ParameterizedTest + @ValueSource(strings = {"1", "12", "123", "12345", "adgd"}) + void validatePassword(String password) { + assertThatCode(() -> new Event("이름", password, "TEST_TOKEN")) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("비밀번호가 다른지 검증한다.") + @Test + void isNotSamePassword() { + String rawPassword = "1234"; + Event event = new Event("이름", rawPassword, "TEST_TOKEN"); + + assertThat(event.isPasswordMismatch(rawPassword)).isFalse(); + } + + @DisplayName("비밀번호가 다른지 검증한다.") + @Test + void isNotSamePassword1() { + String rawPassword = "1234"; + Event event = new Event("이름", "5678", "TEST_TOKEN"); + + assertThat(event.isPasswordMismatch(rawPassword)).isTrue(); + } + + @DisplayName("이름을 수정한다.") + @Test + void renameTest() { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + event.rename("새로운 이름"); + + assertThat(event.getName()).isEqualTo("새로운 이름"); + } + + @DisplayName("계좌 정보에 은행 이름과 계좌 번호가 모두 포함된다.") + @Test + void changeAccountTest() { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + event.changeAccount("토스뱅크", "12345678"); + + assertAll( + () -> assertThat(event.getBankName()).isEqualTo("토스뱅크"), + () -> assertThat(event.getAccountNumber()).isEqualTo("12345678") + ); + } + + @DisplayName("계좌 정보에 은행 이름과 계좌 번호가 모두 포함되지 않으면 예외가 발생한다.") + @Test + void changeAccountTest1() { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + assertThatThrownBy(() -> event.changeAccount("행대뱅크", "")) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("지원하는 은행이면 예외가 발생하지 않는다.") + @ParameterizedTest + @ValueSource(strings = {"토스뱅크", "KB국민은행"}) + void changeAccountTest2(String bankName) { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + assertThatCode(() -> event.changeAccount(bankName, "12345678")) + .doesNotThrowAnyException(); + } + + @DisplayName("지원하지 않는 은행이면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"행대뱅크", "토스 뱅크", "망쵸뱅크", "KB 국민은행"}) + void changeAccountTest3(String bankName) { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + assertThatThrownBy(() -> event.changeAccount(bankName, "12345678")) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("계좌 번호가 8자 이상 30자 이하면 예외가 발생하지 않는다.") + @ParameterizedTest + @ValueSource(strings = {"12345678", "123456789012345678901234567890"}) + void changeAccountTest4(String accountNumber) { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + assertThatCode(() -> event.changeAccount("토스뱅크", accountNumber)) + .doesNotThrowAnyException(); + } + + @DisplayName("계좌 번호가 8자 미만 30자 초과면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"", " ", "1234567", "1234567890123456789012345678901"}) + void changeAccountTest5(String accountNumber) { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + assertThatThrownBy(() -> event.changeAccount("토스뱅크", accountNumber)) + .isInstanceOf(HaengdongException.class); + } + + @DisplayName("계좌 정보를 조회한다.") + @Test + void getBankNameTest() { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + event.changeAccount("토스뱅크", "12345678"); + + assertAll( + () -> assertThat(event.getBankName()).isEqualTo("토스뱅크"), + () -> assertThat(event.getAccountNumber()).isEqualTo("12345678") + ); + } + + @DisplayName("계좌 번호에 공백이 있어도 계좌 정보를 정상적으로 조회한다.") + @Test + void getBankNameTest1() { + Event event = new Event("이름", "1234", "TEST_TOKEN"); + + event.changeAccount("토스뱅크", "1234 5678 9012"); + + assertAll( + () -> assertThat(event.getBankName()).isEqualTo("토스뱅크"), + () -> assertThat(event.getAccountNumber()).isEqualTo("1234 5678 9012") + ); + } +} diff --git a/server/src/test/java/server/haengdong/domain/event/PasswordTest.java b/server/src/test/java/server/haengdong/domain/event/PasswordTest.java new file mode 100644 index 000000000..c504d3aa2 --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/event/PasswordTest.java @@ -0,0 +1,19 @@ +package server.haengdong.domain.event; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import server.haengdong.exception.HaengdongException; + +class PasswordTest { + + @DisplayName("비밀번호는 4자리 숫자 입니다.") + @ParameterizedTest + @ValueSource(strings = {"1", "12", "123", "12345", "adgd"}) + void validatePassword(String rawPassword) { + assertThatCode(() -> new Password(rawPassword)) + .isInstanceOf(HaengdongException.class); + } +} diff --git a/server/src/test/java/server/haengdong/domain/member/MemberBillReportTest.java b/server/src/test/java/server/haengdong/domain/member/MemberBillReportTest.java new file mode 100644 index 000000000..187d117f7 --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/member/MemberBillReportTest.java @@ -0,0 +1,42 @@ +package server.haengdong.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.event.Event; +import server.haengdong.domain.bill.MemberBillReport; +import server.haengdong.support.fixture.Fixture; + +class MemberBillReportTest { + + @DisplayName("지출 목록으로 참가자 정산 리포트를 생성한다.") + @Test + void createByBills() { + Event event = Fixture.EVENT1; + Member member1 = new Member(1L, event, "소하", false); + Member member2 = new Member(2L, event, "감자", false); + Member member3 = new Member(3L, event, "쿠키", false); + Member member4 = new Member(4L, event, "고구마", false); + List members = List.of(member1, member2, member3, member4); + List bills = List.of( + Bill.create(event, "뽕족", 60_000L, members), + Bill.create(event, "인생네컷", 20_000L, members) + ); + + MemberBillReport memberBillReport = MemberBillReport.createByBills(bills); + + assertThat(memberBillReport.getReports()) + .containsAllEntriesOf( + Map.of( + member1, 20_000L, + member2, 20_000L, + member3, 20_000L, + member4, 20_000L + ) + ); + } +} diff --git a/server/src/test/java/server/haengdong/domain/member/UpdatedMembersTest.java b/server/src/test/java/server/haengdong/domain/member/UpdatedMembersTest.java new file mode 100644 index 000000000..9f0338ffb --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/member/UpdatedMembersTest.java @@ -0,0 +1,118 @@ +package server.haengdong.domain.member; + +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.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import server.haengdong.domain.event.Event; +import server.haengdong.exception.HaengdongException; + +class UpdatedMembersTest { + + @DisplayName("이벤트 이름들은 중복될 수 없다.") + @Test + void validateNameUnique() { + Event event = new Event("행동대장 회식", "1234", "1231415jaksdf"); + List members = List.of( + new Member(1L, event, "고구마", false), + new Member(2L, event, "감자", false), + new Member(3L, event, "감자", false) + ); + + assertThatThrownBy(() -> new UpdatedMembers(members)) + .isInstanceOf(HaengdongException.class) + .hasMessage("중복된 참여 인원 이름 변경 요청이 존재합니다."); + } + + @DisplayName("이벤트 회원들은 중복될 수 없다.") + @Test + void validateMemberUnique() { + Event event = new Event("행동대장 회식", "1234", "1231415jaksdf"); + Member member1 = new Member(1L, event, "고구마", false); + Member member2 = new Member(2L, event, "감자", false); + List members = List.of(member1, member2, member2); + + + assertThatThrownBy(() -> new UpdatedMembers(members)) + .isInstanceOf(HaengdongException.class) + .hasMessage("중복된 참여 인원 이름 변경 요청이 존재합니다."); + } + + @DisplayName("이벤트 이름들로 이벤트 참여자들을 생성한다.") + @Test + void create() { + Event event = new Event("행동대장 회식", "1234", "1231415jaksdf"); + Member member1 = new Member(1L, event, "고구마", false); + Member member2 = new Member(2L, event, "감자", false); + Member member3 = new Member(3L, event, "당근", false); + List members = List.of(member1, member2, member3); + + UpdatedMembers eventUpdatedMembers = new UpdatedMembers(members); + assertThat(eventUpdatedMembers.getMembers()).hasSize(3) + .containsExactlyInAnyOrder(member1, member2, member3); + } + + @DisplayName("이벤트의 참여자들 전체가 존재하지 않으면 업데이트할 수 없다.") + @Test + void validateUpdatedMembersExist() { + Event event = new Event("행동대장 회식", "1234", "1231415jaksdf"); + Member member1 = new Member(1L, event, "고구마", false); + Member member2 = new Member(2L, event, "감자", false); + Member member3 = new Member(3L, event, "당근", false); + Member member4 = new Member(4L, event, "양파", false); + List members = List.of(member1, member2, member3, member4); + + Member updateMember1 = new Member(1L, event, "토다리", false); + Member updateMember2 = new Member(2L, event, "쿠키", false); + Member updateMember3 = new Member(3L, event, "백호", false); + UpdatedMembers updatedMembers = new UpdatedMembers(List.of(updateMember1, updateMember2, updateMember3)); + + assertThatThrownBy(() -> updatedMembers.validateUpdatable(members)) + .isInstanceOf(HaengdongException.class) + .hasMessage("업데이트 요청된 참여자 ID 목록과 기존 행사 참여자 ID 목록이 일치하지 않습니다."); + } + + @DisplayName("업데이트할 이름 중에 기존 이벤트의 참여자들의 이름과 중복되면 업데이트할 수 없다.") + @Test + void validateUpdatedNamesUnique() { + Event event = new Event("행동대장 회식", "1234", "1231415jaksdf"); + Member member1 = new Member(1L, event, "고구마", false); + Member member2 = new Member(2L, event, "감자", false); + Member member3 = new Member(3L, event, "당근", false); + Member member4 = new Member(4L, event, "양파", false); + List members = List.of(member1, member2, member3, member4); + + Member updateMember1 = new Member(1L, event, "토다리", false); + Member updateMember2 = new Member(2L, event, "쿠키", false); + Member updateMember3 = new Member(3L, event, "백호", false); + Member updateMember4 = new Member(4L, event, "감자", false); + UpdatedMembers updatedMembers = new UpdatedMembers(List.of(updateMember1, updateMember2, updateMember3, updateMember4)); + + assertThatThrownBy(() -> updatedMembers.validateUpdatable(members)) + .isInstanceOf(HaengdongException.class) + .hasMessage("행사에 중복된 참여자 이름이 존재합니다."); + } + + @DisplayName("이벤트의 참여자들 전체를 업데이트 검증한다.") + @Test + void validateUpdatable() { + Event event = new Event("행동대장 회식", "1234", "1231415jaksdf"); + Member member1 = new Member(1L, event, "고구마", false); + Member member2 = new Member(2L, event, "감자", false); + Member member3 = new Member(3L, event, "당근", false); + Member member4 = new Member(4L, event, "양파", false); + List members = List.of(member1, member2, member3, member4); + + Member updateMember1 = new Member(1L, event, "토다리", false); + Member updateMember2 = new Member(2L, event, "쿠키", false); + Member updateMember3 = new Member(3L, event, "백호", false); + Member updateMember4 = new Member(4L, event, "망쵸", false); + UpdatedMembers updatedMembers = new UpdatedMembers(List.of(updateMember1, updateMember2, updateMember3, updateMember4)); + + assertThatCode(() -> updatedMembers.validateUpdatable(members)) + .doesNotThrowAnyException(); + } +} diff --git a/server/src/test/java/server/haengdong/domain/step/StepTest.java b/server/src/test/java/server/haengdong/domain/step/StepTest.java new file mode 100644 index 000000000..5c5631840 --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/step/StepTest.java @@ -0,0 +1,50 @@ +package server.haengdong.domain.step; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static server.haengdong.support.fixture.Fixture.EVENT1; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; +import server.haengdong.exception.HaengdongException; + +class StepTest { + + @DisplayName("회원 구성이 같은 지출은 Step에 추가될 수 있다.") + @Test + void add1() { + Member member1 = new Member(1L, EVENT1, "감자", false); + Member member2 = new Member(2L, EVENT1, "고구마", false); + Member member3 = new Member(3L, EVENT1, "당근", false); + Member member4 = new Member(4L, EVENT1, "양파", false); + Bill bill1 = Bill.create(EVENT1, "뽕족", 10_000L, List.of(member1, member2, member3, member4)); + Step step = Step.of(bill1); + + Bill bill2 = Bill.create(EVENT1, "인생네컷", 5_000L, List.of(member2, member3, member1, member4)); + + step.add(bill2); + + List bills = step.getBills(); + + assertThat(bills).hasSize(2); + } + + @DisplayName("회원 구성이 댜른 지출은 Step에 추가될 수 없다.") + @Test + void add2() { + Member member1 = new Member(1L, EVENT1, "감자", false); + Member member2 = new Member(2L, EVENT1, "고구마", false); + Member member3 = new Member(3L, EVENT1, "당근", false); + Member member4 = new Member(4L, EVENT1, "양파", false); + Bill bill1 = Bill.create(EVENT1, "뽕족", 10_000L, List.of(member1, member2, member3, member4)); + Step step = Step.of(bill1); + + Bill bill2 = Bill.create(EVENT1, "인생네컷", 5_000L, List.of(member2, member3, member1)); + + assertThatThrownBy(() -> step.add(bill2)) + .isInstanceOf(HaengdongException.class); + } +} diff --git a/server/src/test/java/server/haengdong/domain/step/StepsTest.java b/server/src/test/java/server/haengdong/domain/step/StepsTest.java new file mode 100644 index 000000000..799cb0d4c --- /dev/null +++ b/server/src/test/java/server/haengdong/domain/step/StepsTest.java @@ -0,0 +1,48 @@ +package server.haengdong.domain.step; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static server.haengdong.support.fixture.Fixture.EVENT1; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; + +class StepsTest { + + @DisplayName("지출 목록 순으로 같은 회원 구성인 경우 같은 Step으로 묶는다.") + @Test + void of() { + Member member1 = new Member(1L, EVENT1, "감자", false); + Member member2 = new Member(2L, EVENT1, "고구마", false); + Member member3 = new Member(3L, EVENT1, "당근", false); + Member member4 = new Member(4L, EVENT1, "양파", false); + Bill bill1 = Bill.create(EVENT1, "뽕족", 10_000L, List.of(member1, member2)); + Bill bill2 = Bill.create(EVENT1, "용용선생", 20_000L, List.of(member2, member1)); + Bill bill3 = Bill.create(EVENT1, "보승회관", 30_000L, List.of(member1, member2, member3)); + Bill bill4 = Bill.create(EVENT1, "감자", 40_000L, List.of(member1, member2, member3, member4)); + Bill bill5 = Bill.create(EVENT1, "인생네컷", 5_000L, List.of(member2, member3, member1, member4)); + List bills = List.of(bill1, bill2, bill3, bill4, bill5); + + Steps step = Steps.of(bills); + + List steps = step.getSteps(); + assertAll( + () -> assertThat(steps).hasSize(3), + () -> assertThat(steps.get(0).getBills()).hasSize(2) + .containsExactly(bill1, bill2), + () -> assertThat(steps.get(0).getMembers()).hasSize(2) + .containsExactly(member1, member2), + () -> assertThat(steps.get(1).getBills()).hasSize(1) + .containsExactly(bill3), + () -> assertThat(steps.get(1).getMembers()).hasSize(3) + .containsExactly(member1, member2, member3), + () -> assertThat(steps.get(2).getBills()).hasSize(2) + .containsExactly(bill4, bill5), + () -> assertThat(steps.get(2).getMembers()).hasSize(4) + .containsExactly(member1, member2, member3, member4) + ); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/BillControllerTest.java b/server/src/test/java/server/haengdong/presentation/BillControllerTest.java new file mode 100644 index 000000000..b6658d7b6 --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/BillControllerTest.java @@ -0,0 +1,66 @@ +package server.haengdong.presentation; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import server.haengdong.application.response.BillAppResponse; +import server.haengdong.application.response.BillDetailAppResponse; +import server.haengdong.application.response.BillDetailsAppResponse; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.StepAppResponse; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; +import server.haengdong.support.fixture.Fixture; + +class BillControllerTest extends ControllerTestSupport { + + @DisplayName("전체 지출 내역을 조회한다.") + @Test + void findBills() throws Exception { + Bill bill = Fixture.BILL1; + List bills = List.of(BillAppResponse.of(bill)); + + Member member = Fixture.MEMBER1; + List members = List.of(MemberAppResponse.of(member)); + + StepAppResponse stepAppResponse = new StepAppResponse(bills, members); + given(billService.findSteps(anyString())).willReturn(List.of(stepAppResponse)); + + mockMvc.perform(get("/api/events/{eventId}/bills", "token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.steps").isArray()) + .andExpect(jsonPath("$.steps[0].bills").isArray()) + .andExpect(jsonPath("$.steps[0].bills[0].id").value(bill.getId())) + .andExpect(jsonPath("$.steps[0].bills[0].title").value(bill.getTitle())) + .andExpect(jsonPath("$.steps[0].bills[0].price").value(bill.getPrice())) + .andExpect(jsonPath("$.steps[0].bills[0].isFixed").value(bill.isFixed())) + .andExpect(jsonPath("$.steps[0].members").isArray()) + .andExpect(jsonPath("$.steps[0].members[0].id").value(member.getId())) + .andExpect(jsonPath("$.steps[0].members[0].name").value(member.getName())); + } + + @DisplayName("참여자별 지출 금액을 조회한다.") + @Test + void findBillDetails() throws Exception { + BillDetailsAppResponse appResponse = new BillDetailsAppResponse( + List.of(new BillDetailAppResponse(1L, "토다리", 1000L, false))); + given(billService.findBillDetails(anyString(), anyLong())).willReturn(appResponse); + + mockMvc.perform(get("/api/events/{eventId}/bills/{billId}/details", "TOKEN", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(1L)) + .andExpect(jsonPath("$.members[0].memberName").value("토다리")) + .andExpect(jsonPath("$.members[0].price").value(1000L)) + .andExpect(jsonPath("$.members[0].isFixed").value(false)); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java b/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java new file mode 100644 index 000000000..6ba5540d5 --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java @@ -0,0 +1,49 @@ +package server.haengdong.presentation; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import server.haengdong.application.AuthService; +import server.haengdong.application.BillService; +import server.haengdong.application.EventService; +import server.haengdong.application.MemberService; +import server.haengdong.presentation.admin.AdminBillController; +import server.haengdong.presentation.admin.AdminEventController; +import server.haengdong.presentation.admin.AdminMemberController; + +@WebMvcTest( + controllers = { + AdminEventController.class, + AdminBillController.class, + AdminMemberController.class, + EventController.class, + MemberController.class, + BillController.class + }, + excludeFilters = {@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {WebMvcConfigurer.class})} +) +public abstract class ControllerTestSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected EventService eventService; + + @MockBean + protected AuthService authService; + + @MockBean + protected MemberService memberService; + + @MockBean + protected BillService billService; +} diff --git a/server/src/test/java/server/haengdong/presentation/EventControllerTest.java b/server/src/test/java/server/haengdong/presentation/EventControllerTest.java new file mode 100644 index 000000000..5facbb740 --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/EventControllerTest.java @@ -0,0 +1,101 @@ +package server.haengdong.presentation; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import server.haengdong.application.request.EventAppRequest; +import server.haengdong.application.response.EventAppResponse; +import server.haengdong.application.response.EventDetailAppResponse; +import server.haengdong.application.response.MemberBillReportAppResponse; +import server.haengdong.presentation.request.EventLoginRequest; +import server.haengdong.presentation.request.EventSaveRequest; + + +class EventControllerTest extends ControllerTestSupport { + + @DisplayName("토큰으로 행사를 조회한다.") + @Test + void findEventTest() throws Exception { + String eventId = "망쵸토큰"; + EventDetailAppResponse eventDetailAppResponse = new EventDetailAppResponse("행동대장 회식", "토스뱅크", "1231245"); + given(eventService.findEvent(eventId)).willReturn(eventDetailAppResponse); + + mockMvc.perform(get("/api/events/{eventId}", eventId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.eventName").value("행동대장 회식")); + } + + @DisplayName("참여자별 정산 현황을 조회한다.") + @Test + void getMemberBillReports() throws Exception { + List memberBillReportAppResponses = List.of( + new MemberBillReportAppResponse(1L, "소하", false, 20_000L), + new MemberBillReportAppResponse(2L, "토다리", false, 200_000L) + ); + + given(eventService.getMemberBillReports(any())).willReturn(memberBillReportAppResponses); + + mockMvc.perform(get("/api/events/{eventId}/reports", "망쵸토큰") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].memberId").value(equalTo(1))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].memberName").value(equalTo("소하"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].isDeposited").value(equalTo(false))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[0].price").value(equalTo(20_000))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].memberId").value(equalTo(2))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].memberName").value(equalTo("토다리"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].isDeposited").value(equalTo(false))) + .andExpect(MockMvcResultMatchers.jsonPath("$.reports[1].price").value(equalTo(200_000))); + } + + @DisplayName("이벤트를 생성한다.") + @Test + void saveEvent() throws Exception { + EventSaveRequest eventSaveRequest = new EventSaveRequest("토다리", "0987"); + String requestBody = objectMapper.writeValueAsString(eventSaveRequest); + String eventId = "망쵸토큰"; + EventAppResponse eventAppResponse = new EventAppResponse(eventId); + given(eventService.saveEvent(any(EventAppRequest.class))).willReturn(eventAppResponse); + given(authService.createToken(eventId)).willReturn("jwtToken"); + given(authService.getTokenName()).willReturn("eventToken"); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(cookie().value("eventToken", "jwtToken")) + .andExpect(jsonPath("$.eventId").value("망쵸토큰")); + } + + @DisplayName("행사 어드민이 로그인한다.") + @Test + void loginEvent() throws Exception { + String token = "TOKEN"; + EventLoginRequest eventLoginRequest = new EventLoginRequest("1234"); + String requestBody = objectMapper.writeValueAsString(eventLoginRequest); + given(authService.createToken(token)).willReturn("jwtToken"); + given(authService.getTokenName()).willReturn("eventToken"); + + mockMvc.perform(post("/api/events/{eventId}/login", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(cookie().value("eventToken", "jwtToken")) + .andExpect(status().isOk()); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/MemberControllerTest.java b/server/src/test/java/server/haengdong/presentation/MemberControllerTest.java new file mode 100644 index 000000000..ff824831b --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/MemberControllerTest.java @@ -0,0 +1,77 @@ +package server.haengdong.presentation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.MEMBER1; +import static server.haengdong.support.fixture.Fixture.MEMBER2; +import static server.haengdong.support.fixture.Fixture.MEMBER3; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import server.haengdong.application.response.MemberAppResponse; +import server.haengdong.application.response.MemberDepositAppResponse; +import server.haengdong.application.response.MembersDepositAppResponse; +import server.haengdong.domain.member.Member; + +class MemberControllerTest extends ControllerTestSupport { + + @DisplayName("행사에 참여한 전체 인원을 조회한다.") + @Test + void findAllMembersTest() throws Exception { + Member member1 = MEMBER1; + Member member2 = MEMBER2; + Member member3 = MEMBER3; + List members = List.of( + MemberDepositAppResponse.of(member1), + MemberDepositAppResponse.of(member2), + MemberDepositAppResponse.of(member3) + ); + + MembersDepositAppResponse memberAppResponse = new MembersDepositAppResponse(members); + given(memberService.findAllMembers(anyString())).willReturn(memberAppResponse); + + mockMvc.perform(get("/api/events/{eventId}/members", "TOKEN")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(member1.getId())) + .andExpect(jsonPath("$.members[0].name").value(member1.getName())) + .andExpect(jsonPath("$.members[0].isDeposited").value(member1.isDeposited())) + .andExpect(jsonPath("$.members[1].id").value(member2.getId())) + .andExpect(jsonPath("$.members[1].name").value(member2.getName())) + .andExpect(jsonPath("$.members[1].isDeposited").value(member2.isDeposited())) + .andExpect(jsonPath("$.members[2].id").value(member3.getId())) + .andExpect(jsonPath("$.members[2].name").value(member3.getName())) + .andExpect(jsonPath("$.members[2].isDeposited").value(member3.isDeposited())); + } + + @DisplayName("현재 참여 인원을 조회합니다.") + @Test + void getCurrentMembers() throws Exception { + Member member1 = MEMBER1; + Member member2 = MEMBER2; + List members = List.of( + MemberAppResponse.of(member1), + MemberAppResponse.of(member2) + ); + + given(memberService.getCurrentMembers(any())).willReturn(members); + + mockMvc.perform(get("/api/events/{eventId}/members/current", "TOKEN") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members").isArray()) + .andExpect(jsonPath("$.members[0].id").value(member1.getId())) + .andExpect(jsonPath("$.members[0].name").value(member1.getName())) + .andExpect(jsonPath("$.members[1].id").value(member2.getId())) + .andExpect(jsonPath("$.members[1].name").value(member2.getName())); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/admin/AdminBillControllerTest.java b/server/src/test/java/server/haengdong/presentation/admin/AdminBillControllerTest.java new file mode 100644 index 000000000..e3599a9ee --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/admin/AdminBillControllerTest.java @@ -0,0 +1,116 @@ +package server.haengdong.presentation.admin; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import server.haengdong.exception.HaengdongErrorCode; +import server.haengdong.exception.HaengdongException; +import server.haengdong.presentation.ControllerTestSupport; +import server.haengdong.presentation.request.BillDetailUpdateRequest; +import server.haengdong.presentation.request.BillDetailsUpdateRequest; +import server.haengdong.presentation.request.BillSaveRequest; +import server.haengdong.presentation.request.BillUpdateRequest; + +class AdminBillControllerTest extends ControllerTestSupport { + + @DisplayName("지출 내역을 생성한다.") + @Test + void saveAllBill() throws Exception { + List members = List.of(1L, 2L); + BillSaveRequest request = new BillSaveRequest("뽕족", 10_000L, members); + + String requestBody = objectMapper.writeValueAsString(request); + String eventId = "쿠키토큰"; + + mockMvc.perform(post("/api/admin/events/{eventId}/bills", eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @DisplayName("title이 비어 있는 경우 지출 내역을 생성할 수 없다.") + @Test + void saveAllBill1() throws Exception { + List members = List.of(1L, 2L); + BillSaveRequest request = new BillSaveRequest("", 10_000L, members); + + String requestBody = objectMapper.writeValueAsString(request); + String eventId = "소하토큰"; + + mockMvc.perform(post("/api/admin/events/{eventId}/bills", eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @DisplayName("지출 액션을 수정한다.") + @Test + void updateBill() throws Exception { + BillUpdateRequest request = new BillUpdateRequest("뽕족", 10_000L); + + String requestBody = objectMapper.writeValueAsString(request); + String eventId = "웨디토큰"; + + mockMvc.perform(put("/api/admin/events/{eventId}/bills/{billId}", eventId, 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @DisplayName("지출 내역을 삭제한다.") + @Test + void deleteBill() throws Exception { + String eventId = "토다리토큰"; + + mockMvc.perform(delete("/api/admin/events/{eventId}/bills/{billId}", eventId, 1)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @DisplayName("존재하지 않는 행사에 대한 지출 내역을 삭제할 수 없다.") + @Test + void deleteBill1() throws Exception { + String eventId = "이상해토큰"; + doThrow(new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND)) + .when(billService).deleteBill(any(String.class), any(Long.class)); + + mockMvc.perform(delete("/api/admin/events/{eventId}/bills/{billId}", eventId, 1)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @DisplayName("참여자별 지출 금액을 수정한다.") + @Test + void updateBillDetailsTest() throws Exception { + List billDetailUpdateRequests = List.of( + new BillDetailUpdateRequest(1L, 10000L, true), + new BillDetailUpdateRequest(2L, 20000L, true) + ); + BillDetailsUpdateRequest request = new BillDetailsUpdateRequest( + billDetailUpdateRequests); + + String json = objectMapper.writeValueAsString(request); + + mockMvc.perform(put("/api/admin/events/{eventId}/bills/{billId}/details", "TOKEN", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andDo(print()) + .andExpect(status().isOk()); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java b/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java new file mode 100644 index 000000000..df7b70e92 --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/admin/AdminEventControllerTest.java @@ -0,0 +1,30 @@ +package server.haengdong.presentation.admin; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +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.http.MediaType; +import server.haengdong.presentation.ControllerTestSupport; +import server.haengdong.presentation.request.EventUpdateRequest; + + +class AdminEventControllerTest extends ControllerTestSupport { + + @DisplayName("행사 정보를 수정한다.") + @Test + void updateEventTest() throws Exception { + String token = "TOKEN"; + EventUpdateRequest eventUpdateRequest = new EventUpdateRequest("행동대장 비대위", "행대뱅크", "12345678"); + + String requestBody = objectMapper.writeValueAsString(eventUpdateRequest); + + mockMvc.perform(patch("/api/admin/events/{eventId}", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isOk()); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/admin/AdminMemberControllerTest.java b/server/src/test/java/server/haengdong/presentation/admin/AdminMemberControllerTest.java new file mode 100644 index 000000000..6afb013f9 --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/admin/AdminMemberControllerTest.java @@ -0,0 +1,90 @@ +package server.haengdong.presentation.admin; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static server.haengdong.support.fixture.Fixture.EVENT_COOKIE; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import server.haengdong.application.response.MemberSaveAppResponse; +import server.haengdong.application.response.MembersSaveAppResponse; +import server.haengdong.presentation.ControllerTestSupport; +import server.haengdong.presentation.request.MemberSaveRequest; +import server.haengdong.presentation.request.MemberUpdateRequest; +import server.haengdong.presentation.request.MembersSaveRequest; +import server.haengdong.presentation.request.MembersUpdateRequest; + +class AdminMemberControllerTest extends ControllerTestSupport { + + @DisplayName("행사 참여자를 추가한다.") + @Test + void saveMemberTest() throws Exception { + String eventToken = "망쵸토큰"; + MembersSaveRequest membersSaveRequest = new MembersSaveRequest( + List.of( + new MemberSaveRequest("웨디"), + new MemberSaveRequest("소하") + ) + ); + String requestBody = objectMapper.writeValueAsString(membersSaveRequest); + MembersSaveAppResponse appResponse = new MembersSaveAppResponse( + List.of( + new MemberSaveAppResponse(1L, "웨디"), + new MemberSaveAppResponse(2L, "소하") + ) + ); + given(memberService.saveMembers(eventToken, membersSaveRequest.toAppRequest())) + .willReturn(appResponse); + + mockMvc.perform(post("/api/admin/events/{eventId}/members", "망쵸토큰") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[0].id").value(equalTo(1))) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[0].name").value(equalTo("웨디"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[1].id").value(equalTo(2))) + .andExpect(MockMvcResultMatchers.jsonPath("$.members[1].name").value(equalTo("소하"))); + } + + @DisplayName("행사 참여 인원을 삭제한다.") + @Test + void deleteMember() throws Exception { + String eventId = "망쵸토큰"; + Long memberId = 1L; + + mockMvc.perform(delete("/api/admin/events/{eventId}/members/{memberId}", eventId, memberId)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @DisplayName("행사 참여 인원 정보를 수정한다.") + @Test + void updateMembers() throws Exception { + String eventId = "망쵸토큰"; + MembersUpdateRequest membersUpdateRequest = new MembersUpdateRequest( + List.of( + new MemberUpdateRequest(1L, "토다리", true), + new MemberUpdateRequest(2L, "쿠키", false) + ) + ); + String requestBody = objectMapper.writeValueAsString(membersUpdateRequest); + + mockMvc.perform(put("/api/admin/events/{eventId}/members", eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .cookie(EVENT_COOKIE) + ) + .andDo(print()) + .andExpect(status().isOk()); + } +} diff --git a/server/src/test/java/server/haengdong/support/extension/DatabaseCleaner.java b/server/src/test/java/server/haengdong/support/extension/DatabaseCleaner.java new file mode 100644 index 000000000..346e81d2d --- /dev/null +++ b/server/src/test/java/server/haengdong/support/extension/DatabaseCleaner.java @@ -0,0 +1,52 @@ +package server.haengdong.support.extension; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +class DatabaseCleaner { + + private static final String REFERENTIAL_FORMAT = "set referential_integrity %b;"; + private static final String TRUNCATE_FORMAT = "truncate table %s restart identity;"; + + @PersistenceContext + private EntityManager em; + private String truncateTablesQuery; + + @PostConstruct + public void createTruncateQuery() { + List tableNames = getTableNames(); + StringBuilder stringBuilder = new StringBuilder(); + + for (String tableName : tableNames) { + String truncateQuery = String.format(TRUNCATE_FORMAT, tableName); + stringBuilder.append(truncateQuery); + } + truncateTablesQuery = stringBuilder.toString(); + } + + private List getTableNames() { + String sql = """ + select table_name + from information_schema.tables + where table_schema = 'PUBLIC' + """; + return em.createNativeQuery(sql).getResultList(); + } + + @Transactional + public void clear() { + em.clear(); + truncate(); + } + + private void truncate() { + em.createNativeQuery(String.format(REFERENTIAL_FORMAT, false)).executeUpdate(); + em.createNativeQuery(truncateTablesQuery).executeUpdate(); + em.createNativeQuery(String.format(REFERENTIAL_FORMAT, true)).executeUpdate(); + } +} diff --git a/server/src/test/java/server/haengdong/support/extension/DatabaseCleanerExtension.java b/server/src/test/java/server/haengdong/support/extension/DatabaseCleanerExtension.java new file mode 100644 index 000000000..653ecadb3 --- /dev/null +++ b/server/src/test/java/server/haengdong/support/extension/DatabaseCleanerExtension.java @@ -0,0 +1,19 @@ +package server.haengdong.support.extension; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseCleanerExtension implements AfterEachCallback { + + @Override + public void afterEach(ExtensionContext context) { + DatabaseCleaner databaseCleaner = getDataCleaner(context); + databaseCleaner.clear(); + } + + private DatabaseCleaner getDataCleaner(ExtensionContext extensionContext) { + return SpringExtension.getApplicationContext(extensionContext) + .getBean(DatabaseCleaner.class); + } +} diff --git a/server/src/test/java/server/haengdong/support/fixture/Fixture.java b/server/src/test/java/server/haengdong/support/fixture/Fixture.java new file mode 100644 index 000000000..e05b43c23 --- /dev/null +++ b/server/src/test/java/server/haengdong/support/fixture/Fixture.java @@ -0,0 +1,18 @@ +package server.haengdong.support.fixture; + +import jakarta.servlet.http.Cookie; +import server.haengdong.domain.bill.Bill; +import server.haengdong.domain.member.Member; +import server.haengdong.domain.event.Event; + +public class Fixture { + + public static final Event EVENT1 = new Event("쿠키", "1234", "TOKEN1"); + public static final Event EVENT2 = new Event("웨디", "1234", "TOKEN2"); + public static final Cookie EVENT_COOKIE = new Cookie("eventToken", "토큰토큰"); + public static final Member MEMBER1 = new Member(EVENT1, "토다리"); + public static final Member MEMBER2 = new Member(EVENT1, "쿠키"); + public static final Member MEMBER3 = new Member(EVENT1, "소하"); + public static final Bill BILL1 = new Bill(EVENT1, "행동대장 회식", 10000L); + public static final Bill BILL2 = new Bill(EVENT2, "행동대장 회식2", 20000L); +}