diff --git a/.gitignore b/.gitignore index 7b2e3147..c2065bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,37 @@ -# ---> 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* - -# ---> Gradle +HELP.md .gradle -**/build/ -!src/**/build/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/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) +### STS ### +.apt_generated .classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ +### IntelliJ IDEA ### .idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..7754876d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 next-step + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..e6a12975 --- /dev/null +++ b/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'org.springframework.boot' version '3.2.4' + id 'io.spring.dependency-management' version '1.1.4' + id 'java' +} + +group = 'nextstep' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '17' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:5.3.1' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..41d9927a Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..fae08049 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/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/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# 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 + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + 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 + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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=. +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%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/roomescape/RoomescapeApplication.java b/src/main/java/roomescape/RoomescapeApplication.java new file mode 100644 index 00000000..56ea6335 --- /dev/null +++ b/src/main/java/roomescape/RoomescapeApplication.java @@ -0,0 +1,15 @@ +package roomescape; + +import org.springframework.boot.Banner.Mode; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RoomescapeApplication { + + public static void main(String[] args) { + SpringApplication springApplication = new SpringApplication(RoomescapeApplication.class); + springApplication.setBannerMode(Mode.OFF); + springApplication.run(); + } +} diff --git a/src/main/java/roomescape/member/controller/MemberController.java b/src/main/java/roomescape/member/controller/MemberController.java new file mode 100644 index 00000000..96cf2728 --- /dev/null +++ b/src/main/java/roomescape/member/controller/MemberController.java @@ -0,0 +1,37 @@ +package roomescape.member.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import roomescape.member.dto.MembersResponse; +import roomescape.member.service.MemberService; +import roomescape.system.auth.annotation.Admin; +import roomescape.system.dto.response.RoomEscapeApiResponse; + +@RestController +@Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.") +public class MemberController { + + private final MemberService memberService; + + public MemberController(MemberService memberService) { + this.memberService = memberService; + } + + @Admin + @GetMapping("/members") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "모든 회원 조회", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getAllMembers() { + return RoomEscapeApiResponse.success(memberService.findAllMembers()); + } +} diff --git a/src/main/java/roomescape/member/domain/Member.java b/src/main/java/roomescape/member/domain/Member.java new file mode 100644 index 00000000..5654a7da --- /dev/null +++ b/src/main/java/roomescape/member/domain/Member.java @@ -0,0 +1,98 @@ +package roomescape.member.domain; + +import org.springframework.http.HttpStatus; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Entity +public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private String email; + + private String password; + + @Enumerated(value = EnumType.STRING) + private Role role; + + protected Member() { + } + + public Member( + String name, + String email, + String password, + Role role + ) { + this(null, name, email, password, role); + } + + public Member( + Long id, + String name, + String email, + String password, + Role role + ) { + this.id = id; + this.name = name; + this.email = email; + this.password = password; + this.role = role; + + validateRole(); + } + + private void validateRole() { + if (role == null) { + throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this), + HttpStatus.BAD_REQUEST); + } + } + + public boolean isAdmin() { + return this.role == Role.ADMIN; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + public Role getRole() { + return role; + } + + @Override + public String toString() { + return "Member{" + + "id=" + id + + ", name=" + name + + ", email=" + email + + ", password=" + password + + ", role=" + role + + '}'; + } +} diff --git a/src/main/java/roomescape/member/domain/Role.java b/src/main/java/roomescape/member/domain/Role.java new file mode 100644 index 00000000..e573fc02 --- /dev/null +++ b/src/main/java/roomescape/member/domain/Role.java @@ -0,0 +1,6 @@ +package roomescape.member.domain; + +public enum Role { + MEMBER, + ADMIN +} diff --git a/src/main/java/roomescape/member/domain/repository/MemberRepository.java b/src/main/java/roomescape/member/domain/repository/MemberRepository.java new file mode 100644 index 00000000..4f039582 --- /dev/null +++ b/src/main/java/roomescape/member/domain/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package roomescape.member.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import roomescape.member.domain.Member; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmailAndPassword(String email, String password); +} diff --git a/src/main/java/roomescape/member/dto/MemberResponse.java b/src/main/java/roomescape/member/dto/MemberResponse.java new file mode 100644 index 00000000..8683b6aa --- /dev/null +++ b/src/main/java/roomescape/member/dto/MemberResponse.java @@ -0,0 +1,14 @@ +package roomescape.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import roomescape.member.domain.Member; + +@Schema(name = "회원 조회 응답", description = "회원 정보 조회 응답시 사용됩니다.") +public record MemberResponse( + @Schema(description = "회원 번호. 회원을 식별할 때 사용합니다.") Long id, + @Schema(description = "회원의 이름") String name +) { + public static MemberResponse fromEntity(Member member) { + return new MemberResponse(member.getId(), member.getName()); + } +} diff --git a/src/main/java/roomescape/member/dto/MembersResponse.java b/src/main/java/roomescape/member/dto/MembersResponse.java new file mode 100644 index 00000000..5a83c97d --- /dev/null +++ b/src/main/java/roomescape/member/dto/MembersResponse.java @@ -0,0 +1,11 @@ +package roomescape.member.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "회원 목록 조회 응답", description = "모든 회원의 정보 조회 응답시 사용됩니다.") +public record MembersResponse( + @Schema(description = "모든 회원의 ID 및 이름") List members +) { +} diff --git a/src/main/java/roomescape/member/service/MemberService.java b/src/main/java/roomescape/member/service/MemberService.java new file mode 100644 index 00000000..ff5dbd4c --- /dev/null +++ b/src/main/java/roomescape/member/service/MemberService.java @@ -0,0 +1,47 @@ +package roomescape.member.service; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import roomescape.member.domain.Member; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.member.dto.MemberResponse; +import roomescape.member.dto.MembersResponse; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + public MemberService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Transactional(readOnly = true) + public MembersResponse findAllMembers() { + List response = memberRepository.findAll().stream() + .map(MemberResponse::fromEntity) + .toList(); + + return new MembersResponse(response); + } + + @Transactional(readOnly = true) + public Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new RoomEscapeException(ErrorType.MEMBER_NOT_FOUND, + String.format("[memberId: %d]", memberId), HttpStatus.BAD_REQUEST)); + } + + @Transactional(readOnly = true) + public Member findMemberByEmailAndPassword(String email, String password) { + return memberRepository.findByEmailAndPassword(email, password) + .orElseThrow(() -> new RoomEscapeException(ErrorType.MEMBER_NOT_FOUND, + String.format("[email: %s, password: %s]", email, password), HttpStatus.BAD_REQUEST)); + } +} diff --git a/src/main/java/roomescape/payment/PaymentConfig.java b/src/main/java/roomescape/payment/PaymentConfig.java new file mode 100644 index 00000000..bed158a8 --- /dev/null +++ b/src/main/java/roomescape/payment/PaymentConfig.java @@ -0,0 +1,38 @@ +package roomescape.payment; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import roomescape.payment.client.PaymentProperties; + +@Configuration +@EnableConfigurationProperties(PaymentProperties.class) +public class PaymentConfig { + + @Bean + public RestClient.Builder restClientBuilder(PaymentProperties paymentProperties) { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withReadTimeout(Duration.ofSeconds(paymentProperties.getReadTimeout())) + .withConnectTimeout(Duration.ofSeconds(paymentProperties.getConnectTimeout())); + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings); + + return RestClient.builder().baseUrl("https://api.tosspayments.com") + .defaultHeader("Authorization", getAuthorizations(paymentProperties.getConfirmSecretKey())) + .requestFactory(requestFactory); + } + + private String getAuthorizations(String secretKey) { + Base64.Encoder encoder = Base64.getEncoder(); + byte[] encodedBytes = encoder.encode((secretKey + ":").getBytes(StandardCharsets.UTF_8)); + return "Basic " + new String(encodedBytes); + } +} diff --git a/src/main/java/roomescape/payment/client/PaymentProperties.java b/src/main/java/roomescape/payment/client/PaymentProperties.java new file mode 100644 index 00000000..24584579 --- /dev/null +++ b/src/main/java/roomescape/payment/client/PaymentProperties.java @@ -0,0 +1,29 @@ +package roomescape.payment.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "payment") +public class PaymentProperties { + + private final String confirmSecretKey; + private final int readTimeout; + private final int connectTimeout; + + public PaymentProperties(String confirmSecretKey, int readTimeout, int connectTimeout) { + this.confirmSecretKey = confirmSecretKey; + this.readTimeout = readTimeout; + this.connectTimeout = connectTimeout; + } + + public String getConfirmSecretKey() { + return confirmSecretKey; + } + + public int getReadTimeout() { + return readTimeout; + } + + public int getConnectTimeout() { + return connectTimeout; + } +} diff --git a/src/main/java/roomescape/payment/client/TossPaymentClient.java b/src/main/java/roomescape/payment/client/TossPaymentClient.java new file mode 100644 index 00000000..49c9397a --- /dev/null +++ b/src/main/java/roomescape/payment/client/TossPaymentClient.java @@ -0,0 +1,98 @@ +package roomescape.payment.client; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.request.PaymentRequest; +import roomescape.payment.dto.response.PaymentCancelResponse; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.dto.response.TossPaymentErrorResponse; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Component +public class TossPaymentClient { + + private static final Logger log = LoggerFactory.getLogger(TossPaymentClient.class); + + private final RestClient restClient; + + public TossPaymentClient(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + } + + public PaymentResponse confirmPayment(PaymentRequest paymentRequest) { + logPaymentInfo(paymentRequest); + return restClient.post() + .uri("/v1/payments/confirm") + .contentType(MediaType.APPLICATION_JSON) + .body(paymentRequest) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + (req, res) -> handlePaymentError(res)) + .body(PaymentResponse.class); + } + + public PaymentCancelResponse cancelPayment(PaymentCancelRequest cancelRequest) { + logPaymentCancelInfo(cancelRequest); + Map param = Map.of("cancelReason", cancelRequest.cancelReason()); + + return restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", cancelRequest.paymentKey()) + .contentType(MediaType.APPLICATION_JSON) + .body(param) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + (req, res) -> handlePaymentError(res)) + .body(PaymentCancelResponse.class); + } + + private void logPaymentInfo(PaymentRequest paymentRequest) { + log.info("결제 승인 요청: paymentKey={}, orderId={}, amount={}, paymentType={}", + paymentRequest.paymentKey(), paymentRequest.orderId(), paymentRequest.amount(), + paymentRequest.paymentType()); + } + + private void logPaymentCancelInfo(PaymentCancelRequest cancelRequest) { + log.info("결제 취소 요청: paymentKey={}, amount={}, cancelReason={}", + cancelRequest.paymentKey(), cancelRequest.amount(), cancelRequest.cancelReason()); + } + + private void handlePaymentError(ClientHttpResponse res) + throws IOException { + HttpStatusCode statusCode = res.getStatusCode(); + ErrorType errorType = getErrorTypeByStatusCode(statusCode); + TossPaymentErrorResponse errorResponse = getErrorResponse(res); + + throw new RoomEscapeException(errorType, + String.format("[ErrorCode = %s, ErrorMessage = %s]", errorResponse.code(), errorResponse.message()), + statusCode); + } + + private TossPaymentErrorResponse getErrorResponse(ClientHttpResponse res) throws IOException { + InputStream body = res.getBody(); + ObjectMapper objectMapper = new ObjectMapper(); + TossPaymentErrorResponse errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse.class); + body.close(); + return errorResponse; + } + + private ErrorType getErrorTypeByStatusCode(HttpStatusCode statusCode) { + if (statusCode.is4xxClientError()) { + return ErrorType.PAYMENT_ERROR; + } + return ErrorType.PAYMENT_SERVER_ERROR; + } +} diff --git a/src/main/java/roomescape/payment/domain/CanceledPayment.java b/src/main/java/roomescape/payment/domain/CanceledPayment.java new file mode 100644 index 00000000..dc2056ec --- /dev/null +++ b/src/main/java/roomescape/payment/domain/CanceledPayment.java @@ -0,0 +1,67 @@ +package roomescape.payment.domain; + +import java.time.OffsetDateTime; + +import org.springframework.http.HttpStatus; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Entity +public class CanceledPayment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String paymentKey; + private String cancelReason; + private Long cancelAmount; + private OffsetDateTime approvedAt; + private OffsetDateTime canceledAt; + + protected CanceledPayment() { + } + + public CanceledPayment(String paymentKey, String cancelReason, Long cancelAmount, OffsetDateTime approvedAt, + OffsetDateTime canceledAt) { + validateDate(approvedAt, canceledAt); + this.paymentKey = paymentKey; + this.cancelReason = cancelReason; + this.cancelAmount = cancelAmount; + this.approvedAt = approvedAt; + this.canceledAt = canceledAt; + } + + private void validateDate(OffsetDateTime approvedAt, OffsetDateTime canceledAt) { + if (canceledAt.isBefore(approvedAt)) { + throw new RoomEscapeException(ErrorType.CANCELED_BEFORE_PAYMENT, + String.format("[approvedAt: %s, canceledAt: %s]", approvedAt, canceledAt), + HttpStatus.CONFLICT); + } + } + + public String getCancelReason() { + return cancelReason; + } + + public Long getCancelAmount() { + return cancelAmount; + } + + public OffsetDateTime getApprovedAt() { + return approvedAt; + } + + public OffsetDateTime getCanceledAt() { + return canceledAt; + } + + public void setCanceledAt(OffsetDateTime canceledAt) { + this.canceledAt = canceledAt; + } +} diff --git a/src/main/java/roomescape/payment/domain/Payment.java b/src/main/java/roomescape/payment/domain/Payment.java new file mode 100644 index 00000000..92c1eac1 --- /dev/null +++ b/src/main/java/roomescape/payment/domain/Payment.java @@ -0,0 +1,108 @@ +package roomescape.payment.domain; + +import java.time.OffsetDateTime; + +import org.springframework.http.HttpStatus; + +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.OneToOne; +import roomescape.reservation.domain.Reservation; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Entity +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String orderId; + + @Column(nullable = false) + private String paymentKey; + + @Column(nullable = false) + private Long totalAmount; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + private Reservation reservation; + + @Column(nullable = false) + private OffsetDateTime approvedAt; + + protected Payment() { + } + + public Payment(String orderId, String paymentKey, Long totalAmount, Reservation reservation, + OffsetDateTime approvedAt) { + validate(orderId, paymentKey, totalAmount, reservation, approvedAt); + this.orderId = orderId; + this.paymentKey = paymentKey; + this.totalAmount = totalAmount; + this.reservation = reservation; + this.approvedAt = approvedAt; + } + + private void validate(String orderId, String paymentKey, Long totalAmount, Reservation reservation, + OffsetDateTime approvedAt) { + validateIsNullOrBlank(orderId, "orderId"); + validateIsNullOrBlank(paymentKey, "paymentKey"); + validateIsInvalidAmount(totalAmount); + validateIsNull(reservation, "reservation"); + validateIsNull(approvedAt, "approvedAt"); + } + + private void validateIsNullOrBlank(String input, String fieldName) { + if (input == null || input.isBlank()) { + throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[value : %s]", fieldName), + HttpStatus.BAD_REQUEST); + } + } + + private void validateIsInvalidAmount(Long totalAmount) { + if (totalAmount == null || totalAmount < 0) { + throw new RoomEscapeException(ErrorType.INVALID_REQUEST_DATA, + String.format("[totalAmount : %d]", totalAmount), HttpStatus.BAD_REQUEST); + } + } + + private void validateIsNull(T value, String fieldName) { + if (value == null) { + throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[value : %s]", fieldName), + HttpStatus.BAD_REQUEST); + } + } + + public Long getId() { + return id; + } + + public String getOrderId() { + return orderId; + } + + public String getPaymentKey() { + return paymentKey; + } + + public Long getTotalAmount() { + return totalAmount; + } + + public Reservation getReservation() { + return reservation; + } + + public OffsetDateTime getApprovedAt() { + return approvedAt; + } +} diff --git a/src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java b/src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java new file mode 100644 index 00000000..e3e05a47 --- /dev/null +++ b/src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java @@ -0,0 +1,12 @@ +package roomescape.payment.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import roomescape.payment.domain.CanceledPayment; + +public interface CanceledPaymentRepository extends JpaRepository { + + Optional findByPaymentKey(String paymentKey); +} diff --git a/src/main/java/roomescape/payment/domain/repository/PaymentRepository.java b/src/main/java/roomescape/payment/domain/repository/PaymentRepository.java new file mode 100644 index 00000000..7d55352f --- /dev/null +++ b/src/main/java/roomescape/payment/domain/repository/PaymentRepository.java @@ -0,0 +1,14 @@ +package roomescape.payment.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import roomescape.payment.domain.Payment; + +public interface PaymentRepository extends JpaRepository { + + Optional findByReservationId(Long reservationId); + + Optional findByPaymentKey(String paymentKey); +} diff --git a/src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java b/src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java new file mode 100644 index 00000000..4641575b --- /dev/null +++ b/src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java @@ -0,0 +1,4 @@ +package roomescape.payment.dto.request; + +public record PaymentCancelRequest(String paymentKey, Long amount, String cancelReason) { +} diff --git a/src/main/java/roomescape/payment/dto/request/PaymentRequest.java b/src/main/java/roomescape/payment/dto/request/PaymentRequest.java new file mode 100644 index 00000000..d7f2ea82 --- /dev/null +++ b/src/main/java/roomescape/payment/dto/request/PaymentRequest.java @@ -0,0 +1,4 @@ +package roomescape.payment.dto.request; + +public record PaymentRequest(String paymentKey, String orderId, Long amount, String paymentType) { +} diff --git a/src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java b/src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java new file mode 100644 index 00000000..1c6e2e0e --- /dev/null +++ b/src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java @@ -0,0 +1,14 @@ +package roomescape.payment.dto.response; + +import java.time.OffsetDateTime; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = PaymentCancelResponseDeserializer.class) +public record PaymentCancelResponse( + String cancelStatus, + String cancelReason, + Long cancelAmount, + OffsetDateTime canceledAt +) { +} diff --git a/src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java b/src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java new file mode 100644 index 00000000..96bba21d --- /dev/null +++ b/src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java @@ -0,0 +1,36 @@ +package roomescape.payment.dto.response; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +public class PaymentCancelResponseDeserializer extends StdDeserializer { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern( + "yyyy-MM-dd'T'HH:mm:ssXXX"); + + public PaymentCancelResponseDeserializer() { + this(null); + } + + public PaymentCancelResponseDeserializer(Class vc) { + super(vc); + } + + @Override + public PaymentCancelResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + JsonNode cancels = (JsonNode)jsonParser.getCodec().readTree(jsonParser).get("cancels").get(0); + return new PaymentCancelResponse( + cancels.get("cancelStatus").asText(), + cancels.get("cancelReason").asText(), + cancels.get("cancelAmount").asLong(), + OffsetDateTime.parse(cancels.get("canceledAt").asText()) + ); + } +} diff --git a/src/main/java/roomescape/payment/dto/response/PaymentResponse.java b/src/main/java/roomescape/payment/dto/response/PaymentResponse.java new file mode 100644 index 00000000..afee9601 --- /dev/null +++ b/src/main/java/roomescape/payment/dto/response/PaymentResponse.java @@ -0,0 +1,11 @@ +package roomescape.payment.dto.response; + +import java.time.OffsetDateTime; + +public record PaymentResponse( + String paymentKey, + String orderId, + OffsetDateTime approvedAt, + Long totalAmount +) { +} diff --git a/src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java b/src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java new file mode 100644 index 00000000..479d25e8 --- /dev/null +++ b/src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java @@ -0,0 +1,15 @@ +package roomescape.payment.dto.response; + +import java.time.OffsetDateTime; + +import roomescape.payment.domain.Payment; +import roomescape.reservation.dto.response.ReservationResponse; + +public record ReservationPaymentResponse(Long id, String orderId, String paymentKey, Long totalAmount, + ReservationResponse reservation, OffsetDateTime approvedAt) { + + public static ReservationPaymentResponse from(Payment saved) { + return new ReservationPaymentResponse(saved.getId(), saved.getOrderId(), saved.getPaymentKey(), + saved.getTotalAmount(), ReservationResponse.from(saved.getReservation()), saved.getApprovedAt()); + } +} diff --git a/src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java b/src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java new file mode 100644 index 00000000..2e066de9 --- /dev/null +++ b/src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java @@ -0,0 +1,4 @@ +package roomescape.payment.dto.response; + +public record TossPaymentErrorResponse(String code, String message) { +} diff --git a/src/main/java/roomescape/payment/service/PaymentService.java b/src/main/java/roomescape/payment/service/PaymentService.java new file mode 100644 index 00000000..e3c6332e --- /dev/null +++ b/src/main/java/roomescape/payment/service/PaymentService.java @@ -0,0 +1,82 @@ +package roomescape.payment.service; + +import java.time.OffsetDateTime; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import roomescape.payment.domain.CanceledPayment; +import roomescape.payment.domain.Payment; +import roomescape.payment.domain.repository.CanceledPaymentRepository; +import roomescape.payment.domain.repository.PaymentRepository; +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.response.PaymentCancelResponse; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.dto.response.ReservationPaymentResponse; +import roomescape.reservation.domain.Reservation; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Service +@Transactional +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final CanceledPaymentRepository canceledPaymentRepository; + + public PaymentService(PaymentRepository paymentRepository, CanceledPaymentRepository canceledPaymentRepository) { + this.paymentRepository = paymentRepository; + this.canceledPaymentRepository = canceledPaymentRepository; + } + + public ReservationPaymentResponse savePayment(PaymentResponse paymentResponse, Reservation reservation) { + Payment payment = new Payment(paymentResponse.orderId(), paymentResponse.paymentKey(), + paymentResponse.totalAmount(), reservation, paymentResponse.approvedAt()); + Payment saved = paymentRepository.save(payment); + return ReservationPaymentResponse.from(saved); + } + + @Transactional(readOnly = true) + public Optional findPaymentByReservationId(Long reservationId) { + return paymentRepository.findByReservationId(reservationId); + } + + public void saveCanceledPayment(PaymentCancelResponse cancelInfo, OffsetDateTime approvedAt, String paymentKey) { + canceledPaymentRepository.save(new CanceledPayment( + paymentKey, cancelInfo.cancelReason(), cancelInfo.cancelAmount(), approvedAt, cancelInfo.canceledAt())); + } + + public PaymentCancelRequest cancelPaymentByAdmin(Long reservationId) { + String paymentKey = findPaymentByReservationId(reservationId) + .orElseThrow(() -> new RoomEscapeException(ErrorType.PAYMENT_NOT_POUND, + String.format("[reservationId: %d]", reservationId), HttpStatus.NOT_FOUND)) + .getPaymentKey(); + // 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다. + CanceledPayment canceled = cancelPayment(paymentKey, "고객 요청", OffsetDateTime.now()); + + return new PaymentCancelRequest(paymentKey, canceled.getCancelAmount(), canceled.getCancelReason()); + } + + private CanceledPayment cancelPayment(String paymentKey, String cancelReason, OffsetDateTime canceledAt) { + Payment payment = paymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> throwPaymentNotFoundByPaymentKey(paymentKey)); + paymentRepository.delete(payment); + + return canceledPaymentRepository.save(new CanceledPayment(paymentKey, cancelReason, payment.getTotalAmount(), + payment.getApprovedAt(), canceledAt)); + } + + public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) { + CanceledPayment canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> throwPaymentNotFoundByPaymentKey(paymentKey)); + canceledPayment.setCanceledAt(canceledAt); + } + + private RoomEscapeException throwPaymentNotFoundByPaymentKey(String paymentKey) { + return new RoomEscapeException( + ErrorType.PAYMENT_NOT_POUND, String.format("[paymentKey: %s]", paymentKey), + HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/roomescape/reservation/controller/ReservationController.java b/src/main/java/roomescape/reservation/controller/ReservationController.java new file mode 100644 index 00000000..2836c12d --- /dev/null +++ b/src/main/java/roomescape/reservation/controller/ReservationController.java @@ -0,0 +1,273 @@ +package roomescape.reservation.controller; + +import java.time.LocalDate; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import roomescape.payment.client.TossPaymentClient; +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.request.PaymentRequest; +import roomescape.payment.dto.response.PaymentCancelResponse; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.reservation.dto.request.AdminReservationRequest; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.request.WaitingRequest; +import roomescape.reservation.dto.response.MyReservationsResponse; +import roomescape.reservation.dto.response.ReservationResponse; +import roomescape.reservation.dto.response.ReservationsResponse; +import roomescape.reservation.service.ReservationService; +import roomescape.reservation.service.ReservationWithPaymentService; +import roomescape.system.auth.annotation.Admin; +import roomescape.system.auth.annotation.LoginRequired; +import roomescape.system.auth.annotation.MemberId; +import roomescape.system.dto.response.ErrorResponse; +import roomescape.system.dto.response.RoomEscapeApiResponse; +import roomescape.system.exception.RoomEscapeException; + +@RestController +@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.") +public class ReservationController { + + private final ReservationWithPaymentService reservationWithPaymentService; + private final ReservationService reservationService; + private final TossPaymentClient paymentClient; + + public ReservationController(ReservationWithPaymentService reservationWithPaymentService, + ReservationService reservationService, TossPaymentClient paymentClient) { + this.reservationWithPaymentService = reservationWithPaymentService; + this.reservationService = reservationService; + this.paymentClient = paymentClient; + } + + @Admin + @GetMapping("/reservations") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "모든 예약 정보 조회", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getAllReservations() { + return RoomEscapeApiResponse.success(reservationService.findAllReservations()); + } + + @LoginRequired + @GetMapping("/reservations-mine") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "자신의 예약 및 대기 조회", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getMemberReservations( + @MemberId @Parameter(hidden = true) Long memberId) { + return RoomEscapeApiResponse.success(reservationService.findMemberReservations(memberId)); + } + + @Admin + @GetMapping("/reservations/search") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "날짜 범위를 지정할 때, 종료 날짜는 시작 날짜 이전일 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse getReservationBySearching( + @RequestParam(required = false) @Parameter(description = "테마 ID") Long themeId, + @RequestParam(required = false) @Parameter(description = "회원 ID") Long memberId, + @RequestParam(required = false) @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요", example = "2024-06-10") LocalDate dateFrom, + @RequestParam(required = false) @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요", example = "2024-06-10") LocalDate dateTo + ) { + return RoomEscapeApiResponse.success( + reservationService.findFilteredReservations(themeId, memberId, dateFrom, dateTo)); + } + + @Admin + @DeleteMapping("/reservations/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "관리자의 예약 취소", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse(responseCode = "404", description = "예약 또는 결제 정보를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + public RoomEscapeApiResponse removeReservation( + @MemberId @Parameter(hidden = true) Long memberId, + @NotNull(message = "reservationId는 null일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId + ) { + + if (reservationWithPaymentService.isNotPaidReservation(reservationId)) { + reservationService.removeReservationById(reservationId, memberId); + return RoomEscapeApiResponse.success(); + } + + PaymentCancelRequest paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment( + reservationId, memberId); + + PaymentCancelResponse paymentCancelResponse = paymentClient.cancelPayment(paymentCancelRequest); + + reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey(), + paymentCancelResponse.canceledAt()); + + return RoomEscapeApiResponse.success(); + } + + @LoginRequired + @PostMapping("/reservations") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "예약 추가", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true, + headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1"))) + }) + public RoomEscapeApiResponse saveReservation( + @Valid @RequestBody ReservationRequest reservationRequest, + @MemberId @Parameter(hidden = true) Long memberId, + HttpServletResponse response + ) { + PaymentRequest paymentRequest = reservationRequest.getPaymentRequest(); + PaymentResponse paymentResponse = paymentClient.confirmPayment(paymentRequest); + + try { + ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment( + reservationRequest, paymentResponse, memberId); + return getCreatedReservationResponse(reservationResponse, response); + } catch (RoomEscapeException e) { + PaymentCancelRequest cancelRequest = new PaymentCancelRequest(paymentRequest.paymentKey(), + paymentRequest.amount(), e.getMessage()); + + PaymentCancelResponse paymentCancelResponse = paymentClient.cancelPayment(cancelRequest); + + reservationWithPaymentService.saveCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt(), + paymentRequest.paymentKey()); + throw e; + } + } + + @Admin + @PostMapping("/reservations/admin") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "관리자 예약 추가", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true, + headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1"))), + @ApiResponse(responseCode = "409", description = "예약이 이미 존재합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse saveReservationByAdmin( + @Valid @RequestBody AdminReservationRequest adminReservationRequest, + HttpServletResponse response + ) { + ReservationResponse reservationResponse = reservationService.addReservationByAdmin(adminReservationRequest); + return getCreatedReservationResponse(reservationResponse, response); + } + + @Admin + @GetMapping("/reservations/waiting") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "모든 예약 대기 조회", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getAllWaiting() { + return RoomEscapeApiResponse.success(reservationService.findAllWaiting()); + } + + @LoginRequired + @PostMapping("/reservations/waiting") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "예약 대기 신청", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true, + headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1"))) + }) + public RoomEscapeApiResponse saveWaiting( + @Valid @RequestBody WaitingRequest waitingRequest, + @MemberId @Parameter(hidden = true) Long memberId, + HttpServletResponse response + ) { + ReservationResponse reservationResponse = reservationService.addWaiting(waitingRequest, memberId); + return getCreatedReservationResponse(reservationResponse, response); + } + + @LoginRequired + @DeleteMapping("/reservations/waiting/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "예약 대기 취소", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "성공"), + @ApiResponse(responseCode = "404", description = "회원의 예약 대기 정보를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse deleteWaiting( + @MemberId @Parameter(hidden = true) Long memberId, + @NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId + ) { + reservationService.cancelWaiting(reservationId, memberId); + return RoomEscapeApiResponse.success(); + } + + @Admin + @PostMapping("/reservations/waiting/{id}/approve") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "대기 중인 예약 승인", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "404", description = "예약 대기 정보를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "확정된 예약이 존재하여 대기 중인 예약을 승인할 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse approveWaiting( + @MemberId @Parameter(hidden = true) Long memberId, + @NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId + ) { + reservationService.approveWaiting(reservationId, memberId); + + return RoomEscapeApiResponse.success(); + } + + @Admin + @PostMapping("/reservations/waiting/{id}/deny") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "대기 중인 예약 거절", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"), + @ApiResponse(responseCode = "404", description = "예약 대기 정보를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse denyWaiting( + @MemberId @Parameter(hidden = true) Long memberId, + @NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId + ) { + reservationService.denyWaiting(reservationId, memberId); + + return RoomEscapeApiResponse.success(); + } + + private RoomEscapeApiResponse getCreatedReservationResponse( + ReservationResponse reservationResponse, + HttpServletResponse response + ) { + response.setHeader(HttpHeaders.LOCATION, "/reservations/" + reservationResponse.id()); + return RoomEscapeApiResponse.success(reservationResponse); + } +} diff --git a/src/main/java/roomescape/reservation/controller/ReservationTimeController.java b/src/main/java/roomescape/reservation/controller/ReservationTimeController.java new file mode 100644 index 00000000..bd6d3fd8 --- /dev/null +++ b/src/main/java/roomescape/reservation/controller/ReservationTimeController.java @@ -0,0 +1,112 @@ +package roomescape.reservation.controller; + +import java.time.LocalDate; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import roomescape.reservation.dto.request.ReservationTimeRequest; +import roomescape.reservation.dto.response.ReservationTimeInfosResponse; +import roomescape.reservation.dto.response.ReservationTimeResponse; +import roomescape.reservation.dto.response.ReservationTimesResponse; +import roomescape.reservation.service.ReservationTimeService; +import roomescape.system.auth.annotation.Admin; +import roomescape.system.auth.annotation.LoginRequired; +import roomescape.system.dto.response.ErrorResponse; +import roomescape.system.dto.response.RoomEscapeApiResponse; + +@RestController +@Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.") +public class ReservationTimeController { + + private final ReservationTimeService reservationTimeService; + + public ReservationTimeController(ReservationTimeService reservationTimeService) { + this.reservationTimeService = reservationTimeService; + } + + @Admin + @GetMapping("/times") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "모든 시간 조회", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getAllTimes() { + return RoomEscapeApiResponse.success(reservationTimeService.findAllTimes()); + } + + @Admin + @PostMapping("/times") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "시간 추가", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "409", description = "같은 시간을 추가할 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse saveTime( + @Valid @RequestBody ReservationTimeRequest reservationTimeRequest, + HttpServletResponse response + ) { + ReservationTimeResponse reservationTimeResponse = reservationTimeService.addTime(reservationTimeRequest); + response.setHeader(HttpHeaders.LOCATION, "/times/" + reservationTimeResponse.id()); + + return RoomEscapeApiResponse.success(reservationTimeResponse); + } + + @Admin + @DeleteMapping("/times/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "시간 삭제", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "409", description = "예약된 시간은 삭제할 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse removeTime( + @NotNull(message = "timeId는 null 또는 공백일 수 없습니다.") @PathVariable @Parameter(description = "삭제하고자 하는 시간의 ID값") Long id + ) { + reservationTimeService.removeTimeById(id); + + return RoomEscapeApiResponse.success(); + } + + @LoginRequired + @GetMapping("/times/filter") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "예약 가능 여부를 포함한 모든 시간 조회", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse findAllAvailableReservationTimes( + @NotNull(message = "날짜는 null일 수 없습니다.") + @RequestParam + @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요.", example = "2024-06-10") + LocalDate date, + @NotNull(message = "themeId는 null일 수 없습니다.") + @RequestParam + @Parameter(description = "조회할 테마의 ID를 입력해주세요.", example = "1") + Long themeId + ) { + return RoomEscapeApiResponse.success(reservationTimeService.findAllAvailableTimesByDateAndTheme(date, themeId)); + } +} diff --git a/src/main/java/roomescape/reservation/domain/Reservation.java b/src/main/java/roomescape/reservation/domain/Reservation.java new file mode 100644 index 00000000..8fee6fc6 --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/Reservation.java @@ -0,0 +1,127 @@ +package roomescape.reservation.domain; + +import java.time.LocalDate; + +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import roomescape.member.domain.Member; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; + +@Entity +public class Reservation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDate date; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id", nullable = false) + private ReservationTime reservationTime; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id", nullable = false) + private Theme theme; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Enumerated(value = EnumType.STRING) + private ReservationStatus reservationStatus; + + protected Reservation() { + } + + public Reservation( + LocalDate date, + ReservationTime reservationTime, + Theme theme, + Member member, + ReservationStatus status + ) { + this(null, date, reservationTime, theme, member, status); + } + + public Reservation( + Long id, + LocalDate date, + ReservationTime reservationTime, + Theme theme, + Member member, + ReservationStatus status + ) { + validateIsNull(date, reservationTime, theme, member, status); + this.id = id; + this.date = date; + this.reservationTime = reservationTime; + this.theme = theme; + this.member = member; + this.reservationStatus = status; + } + + private void validateIsNull(LocalDate date, ReservationTime reservationTime, Theme theme, Member member, + ReservationStatus reservationStatus) { + if (date == null || reservationTime == null || theme == null || member == null || reservationStatus == null) { + throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this), + HttpStatus.BAD_REQUEST); + } + } + + public Long getMemberId() { + return member.getId(); + } + + public Long getId() { + return id; + } + + public LocalDate getDate() { + return date; + } + + public ReservationTime getReservationTime() { + return reservationTime; + } + + public Theme getTheme() { + return theme; + } + + public Member getMember() { + return member; + } + + public ReservationStatus getReservationStatus() { + return reservationStatus; + } + + @JsonIgnore + public boolean isSameDateAndTime(LocalDate date, ReservationTime time) { + return this.date.equals(date) && time.getStartAt().equals(this.reservationTime.getStartAt()); + } + + @JsonIgnore + public boolean isWaiting() { + return reservationStatus == ReservationStatus.WAITING; + } + + @JsonIgnore + public boolean isSameMember(Long memberId) { + return getMemberId().equals(memberId); + } +} diff --git a/src/main/java/roomescape/reservation/domain/ReservationStatus.java b/src/main/java/roomescape/reservation/domain/ReservationStatus.java new file mode 100644 index 00000000..d8978397 --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/ReservationStatus.java @@ -0,0 +1,13 @@ +package roomescape.reservation.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "예약 상태를 나타냅니다.", allowableValues = {"CONFIRMED", "CONFIRMED_PAYMENT_REQUIRED", "WAITING"}) +public enum ReservationStatus { + @Schema(description = "결제가 완료된 예약") + CONFIRMED, + @Schema(description = "결제가 필요한 예약") + CONFIRMED_PAYMENT_REQUIRED, + @Schema(description = "대기 중인 예약") + WAITING; +} diff --git a/src/main/java/roomescape/reservation/domain/ReservationTime.java b/src/main/java/roomescape/reservation/domain/ReservationTime.java new file mode 100644 index 00000000..5d3f2705 --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/ReservationTime.java @@ -0,0 +1,59 @@ +package roomescape.reservation.domain; + +import java.time.LocalTime; + +import org.springframework.http.HttpStatus; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Entity +public class ReservationTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalTime startAt; + + protected ReservationTime() { + } + + public ReservationTime(final LocalTime startAt) { + this(null, startAt); + } + + public ReservationTime(final Long id, final LocalTime startAt) { + this.id = id; + this.startAt = startAt; + + validateNull(); + } + + private void validateNull() { + if (startAt == null) { + throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this), + HttpStatus.BAD_REQUEST); + } + } + + public Long getId() { + return id; + } + + public LocalTime getStartAt() { + return startAt; + } + + @Override + public String toString() { + return "ReservationTime{" + + "id=" + id + + ", startAt=" + startAt + + '}'; + } +} diff --git a/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java b/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java new file mode 100644 index 00000000..e89d7279 --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java @@ -0,0 +1,62 @@ +package roomescape.reservation.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.dto.response.MyReservationResponse; + +public interface ReservationRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByReservationTime(ReservationTime reservationTime); + + List findByThemeId(Long themeId); + + @Modifying + @Query(""" + UPDATE Reservation r + SET r.reservationStatus = :status + WHERE r.id = :id + """) + int updateStatusByReservationId(@Param(value = "id") Long reservationId, + @Param(value = "status") ReservationStatus statusForChange); + + @Query(""" + SELECT EXISTS ( + SELECT 1 FROM Reservation r + WHERE r.theme.id = r2.theme.id + AND r.reservationTime.id = r2.reservationTime.id + AND r.date = r2.date + AND r.reservationStatus != 'WAITING' + ) + FROM Reservation r2 + WHERE r2.id = :id + """) + boolean isExistConfirmedReservation(@Param("id") Long reservationId); + + @Query(""" + SELECT new roomescape.reservation.dto.response.MyReservationResponse( + r.id, + t.name, + r.date, + r.reservationTime.startAt, + r.reservationStatus, + (SELECT COUNT (r2) FROM Reservation r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.reservationTime = r.reservationTime AND r2.id < r.id), + p.paymentKey, + p.totalAmount + ) + FROM Reservation r + JOIN r.theme t + LEFT JOIN Payment p + ON p.reservation = r + WHERE r.member.id = :memberId + """) + List findMyReservations(Long memberId); +} diff --git a/src/main/java/roomescape/reservation/domain/repository/ReservationSearchSpecification.java b/src/main/java/roomescape/reservation/domain/repository/ReservationSearchSpecification.java new file mode 100644 index 00000000..69eb914a --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/repository/ReservationSearchSpecification.java @@ -0,0 +1,87 @@ +package roomescape.reservation.domain.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.domain.Specification; + +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; + +public class ReservationSearchSpecification { + + private Specification spec; + + public ReservationSearchSpecification() { + this.spec = Specification.where(null); + } + + public ReservationSearchSpecification sameThemeId(Long themeId) { + if (themeId != null) { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("theme").get("id"), themeId)); + } + return this; + } + + public ReservationSearchSpecification sameMemberId(Long memberId) { + if (memberId != null) { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("member").get("id"), memberId)); + } + return this; + } + + public ReservationSearchSpecification sameTimeId(Long timeId) { + if (timeId != null) { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("reservationTime").get("id"), + timeId)); + } + return this; + } + + public ReservationSearchSpecification sameDate(LocalDate date) { + if (date != null) { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("date"), date)); + } + return this; + } + + public ReservationSearchSpecification confirmed() { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.or( + criteriaBuilder.equal(root.get("reservationStatus"), ReservationStatus.CONFIRMED), + criteriaBuilder.equal(root.get("reservationStatus"), + ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) + )); + return this; + } + + public ReservationSearchSpecification waiting() { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("reservationStatus"), + ReservationStatus.WAITING)); + return this; + } + + public ReservationSearchSpecification dateStartFrom(LocalDate dateFrom) { + if (dateFrom != null) { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.greaterThanOrEqualTo(root.get("date"), dateFrom)); + } + return this; + } + + public ReservationSearchSpecification dateEndAt(LocalDate toDate) { + if (toDate != null) { + this.spec = this.spec.and( + (root, query, criteriaBuilder) -> criteriaBuilder.lessThanOrEqualTo(root.get("date"), toDate)); + } + return this; + } + + public Specification build() { + return this.spec; + } +} diff --git a/src/main/java/roomescape/reservation/domain/repository/ReservationTimeRepository.java b/src/main/java/roomescape/reservation/domain/repository/ReservationTimeRepository.java new file mode 100644 index 00000000..791077d2 --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/repository/ReservationTimeRepository.java @@ -0,0 +1,13 @@ +package roomescape.reservation.domain.repository; + +import java.time.LocalTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import roomescape.reservation.domain.ReservationTime; + +public interface ReservationTimeRepository extends JpaRepository { + + List findByStartAt(LocalTime startAt); +} diff --git a/src/main/java/roomescape/reservation/dto/request/AdminReservationRequest.java b/src/main/java/roomescape/reservation/dto/request/AdminReservationRequest.java new file mode 100644 index 00000000..5b2ea0e1 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/request/AdminReservationRequest.java @@ -0,0 +1,18 @@ +package roomescape.reservation.dto.request; + +import java.time.LocalDate; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "관리자 예약 저장 요청", description = "관리자의 예약 저장 요청시 사용됩니다.") +public record AdminReservationRequest( + @Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31") + LocalDate date, + @Schema(description = "예약 시간 ID.", example = "1") + Long timeId, + @Schema(description = "테마 ID", example = "1") + Long themeId, + @Schema(description = "회원 ID", example = "1") + Long memberId +) { +} diff --git a/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java b/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java new file mode 100644 index 00000000..28f1a363 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java @@ -0,0 +1,36 @@ +package roomescape.reservation.dto.request; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import roomescape.payment.dto.request.PaymentRequest; + +@Schema(name = "회원의 예약 저장 요청", description = "회원의 예약 요청시 사용됩니다.") +public record ReservationRequest( + @NotNull(message = "예약 날짜는 null일 수 없습니다.") + @Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31") + LocalDate date, + @NotNull(message = "예약 요청의 timeId는 null일 수 없습니다.") + @Schema(description = "예약 시간 ID.", example = "1") + Long timeId, + @NotNull(message = "예약 요청의 themeId는 null일 수 없습니다.") + @Schema(description = "테마 ID", example = "1") + Long themeId, + @Schema(description = "결제 위젯을 통해 받은 결제 키") + String paymentKey, + @Schema(description = "결제 위젯을 통해 받은 주문번호.") + String orderId, + @Schema(description = "결제 위젯을 통해 받은 결제 금액") + Long amount, + @Schema(description = "결제 타입", example = "NORMAL") + String paymentType +) { + + @JsonIgnore + public PaymentRequest getPaymentRequest() { + return new PaymentRequest(paymentKey, orderId, amount, paymentType); + } +} diff --git a/src/main/java/roomescape/reservation/dto/request/ReservationTimeRequest.java b/src/main/java/roomescape/reservation/dto/request/ReservationTimeRequest.java new file mode 100644 index 00000000..00db2360 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/request/ReservationTimeRequest.java @@ -0,0 +1,31 @@ +package roomescape.reservation.dto.request; + +import java.time.LocalTime; + +import org.springframework.http.HttpStatus; + +import io.micrometer.common.util.StringUtils; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import roomescape.reservation.domain.ReservationTime; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.") +public record ReservationTimeRequest( + @NotNull(message = "예약 시간은 null일 수 없습니다.") + @Schema(description = "예약 시간. HH:mm 형식으로 입력해야 합니다.", type = "string", example = "09:00") + LocalTime startAt +) { + + public ReservationTimeRequest { + if (StringUtils.isBlank(startAt.toString())) { + throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, + String.format("[values: %s]", this), HttpStatus.BAD_REQUEST); + } + } + + public ReservationTime toTime() { + return new ReservationTime(this.startAt); + } +} diff --git a/src/main/java/roomescape/reservation/dto/request/WaitingRequest.java b/src/main/java/roomescape/reservation/dto/request/WaitingRequest.java new file mode 100644 index 00000000..0568a670 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/request/WaitingRequest.java @@ -0,0 +1,20 @@ +package roomescape.reservation.dto.request; + +import java.time.LocalDate; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "예약 대기 저장 요청", description = "회원의 예약 대기 요청시 사용됩니다.") +public record WaitingRequest( + @NotNull(message = "예약 날짜는 null일 수 없습니다.") + @Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31") + LocalDate date, + @NotNull(message = "예약 요청의 timeId는 null일 수 없습니다.") + @Schema(description = "예약 시간 ID", example = "1") + Long timeId, + @NotNull(message = "예약 요청의 themeId는 null일 수 없습니다.") + @Schema(description = "테마 ID", example = "1") + Long themeId +) { +} diff --git a/src/main/java/roomescape/reservation/dto/response/MyReservationResponse.java b/src/main/java/roomescape/reservation/dto/response/MyReservationResponse.java new file mode 100644 index 00000000..8667c371 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/MyReservationResponse.java @@ -0,0 +1,33 @@ +package roomescape.reservation.dto.response; + +import java.time.LocalDate; +import java.time.LocalTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import roomescape.reservation.domain.ReservationStatus; + +@Schema(name = "회원의 예약 및 대기 응답", description = "회원의 예약 및 대기 정보 응답시 사용됩니다.") +public record MyReservationResponse( + @Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.") + Long id, + @Schema(description = "테마 이름") + String themeName, + @Schema(description = "예약 날짜", type = "string", example = "2022-12-31") + LocalDate date, + @Schema(description = "예약 시간", type = "string", example = "09:00") + LocalTime time, + @Schema(description = "예약 상태", type = "string") + ReservationStatus status, + @Schema(description = "예약 대기 상태일 때의 대기 순번. 확정된 예약은 0의 값을 가집니다.") + Long rank, + @Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.") + String paymentKey, + @Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.") + Long amount +) { + + public MyReservationResponse(Long id, String themeName, LocalDate date, LocalTime time, ReservationStatus status, + Integer rank, String paymentKey, Long amount) { + this(id, themeName, date, time, status, rank.longValue(), paymentKey, amount); + } +} diff --git a/src/main/java/roomescape/reservation/dto/response/MyReservationsResponse.java b/src/main/java/roomescape/reservation/dto/response/MyReservationsResponse.java new file mode 100644 index 00000000..a32ef460 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/MyReservationsResponse.java @@ -0,0 +1,11 @@ +package roomescape.reservation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "회원의 예약 및 대기 목록 조회 응답", description = "회원의 예약 및 대기 목록 조회 응답시 사용됩니다.") +public record MyReservationsResponse( + @Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") List myReservationResponses +) { +} diff --git a/src/main/java/roomescape/reservation/dto/response/ReservationResponse.java b/src/main/java/roomescape/reservation/dto/response/ReservationResponse.java new file mode 100644 index 00000000..c4d5c8fa --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/ReservationResponse.java @@ -0,0 +1,42 @@ +package roomescape.reservation.dto.response; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; +import roomescape.member.dto.MemberResponse; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.theme.dto.ThemeResponse; + +@Schema(name = "예약 정보", description = "예약 저장 및 조회 응답에 사용됩니다.") +public record ReservationResponse( + @Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.") + Long id, + @Schema(description = "예약 날짜", type = "string", example = "2022-12-31") + LocalDate date, + @JsonProperty("member") + @Schema(description = "예약한 회원 정보") + MemberResponse member, + @JsonProperty("time") + @Schema(description = "예약 시간 정보") + ReservationTimeResponse time, + @JsonProperty("theme") + @Schema(description = "예약한 테마 정보") + ThemeResponse theme, + @Schema(description = "예약 상태", type = "string") + ReservationStatus status +) { + + public static ReservationResponse from(Reservation reservation) { + return new ReservationResponse( + reservation.getId(), + reservation.getDate(), + MemberResponse.fromEntity(reservation.getMember()), + ReservationTimeResponse.from(reservation.getReservationTime()), + ThemeResponse.from(reservation.getTheme()), + reservation.getReservationStatus() + ); + } +} diff --git a/src/main/java/roomescape/reservation/dto/response/ReservationTimeInfoResponse.java b/src/main/java/roomescape/reservation/dto/response/ReservationTimeInfoResponse.java new file mode 100644 index 00000000..1105bf03 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/ReservationTimeInfoResponse.java @@ -0,0 +1,16 @@ +package roomescape.reservation.dto.response; + +import java.time.LocalTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "특정 테마, 날짜에 대한 시간 정보 응답", description = "특정 날짜와 테마에 대해, 예약 가능 여부를 포함한 시간 정보를 저장합니다.") +public record ReservationTimeInfoResponse( + @Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.") + Long timeId, + @Schema(description = "예약 시간", type = "string", example = "09:00") + LocalTime startAt, + @Schema(description = "이미 예약이 완료된 시간인지 여부") + boolean alreadyBooked +) { +} diff --git a/src/main/java/roomescape/reservation/dto/response/ReservationTimeInfosResponse.java b/src/main/java/roomescape/reservation/dto/response/ReservationTimeInfosResponse.java new file mode 100644 index 00000000..ef02deeb --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/ReservationTimeInfosResponse.java @@ -0,0 +1,11 @@ +package roomescape.reservation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "예약 시간 정보 목록 응답", description = "특정 테마, 날짜에 대한 모든 예약 가능 시간 정보를 저장합니다.") +public record ReservationTimeInfosResponse( + @Schema(description = "특정 테마, 날짜에 대한 예약 가능 여부를 포함한 시간 목록") List reservationTimes +) { +} diff --git a/src/main/java/roomescape/reservation/dto/response/ReservationTimeResponse.java b/src/main/java/roomescape/reservation/dto/response/ReservationTimeResponse.java new file mode 100644 index 00000000..fc27d3d5 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/ReservationTimeResponse.java @@ -0,0 +1,19 @@ +package roomescape.reservation.dto.response; + +import java.time.LocalTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import roomescape.reservation.domain.ReservationTime; + +@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.") +public record ReservationTimeResponse( + @Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.") + Long id, + @Schema(description = "예약 시간", type = "string", example = "09:00") + LocalTime startAt +) { + + public static ReservationTimeResponse from(ReservationTime reservationTime) { + return new ReservationTimeResponse(reservationTime.getId(), reservationTime.getStartAt()); + } +} diff --git a/src/main/java/roomescape/reservation/dto/response/ReservationTimesResponse.java b/src/main/java/roomescape/reservation/dto/response/ReservationTimesResponse.java new file mode 100644 index 00000000..1cbff917 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/ReservationTimesResponse.java @@ -0,0 +1,11 @@ +package roomescape.reservation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "예약 시간 정보 목록 응답", description = "모든 예약 시간 조회 응답시 사용됩니다.") +public record ReservationTimesResponse( + @Schema(description = "모든 시간 목록") List times +) { +} diff --git a/src/main/java/roomescape/reservation/dto/response/ReservationsResponse.java b/src/main/java/roomescape/reservation/dto/response/ReservationsResponse.java new file mode 100644 index 00000000..83386f1d --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/response/ReservationsResponse.java @@ -0,0 +1,11 @@ +package roomescape.reservation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "예약 목록 조회 응답", description = "모든 예약 정보 조회 응답시 사용됩니다.") +public record ReservationsResponse( + @Schema(description = "모든 예약 및 대기 목록") List reservations +) { +} diff --git a/src/main/java/roomescape/reservation/service/ReservationService.java b/src/main/java/roomescape/reservation/service/ReservationService.java new file mode 100644 index 00000000..05f22a41 --- /dev/null +++ b/src/main/java/roomescape/reservation/service/ReservationService.java @@ -0,0 +1,227 @@ +package roomescape.reservation.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import roomescape.member.domain.Member; +import roomescape.member.service.MemberService; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationSearchSpecification; +import roomescape.reservation.dto.request.AdminReservationRequest; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.request.WaitingRequest; +import roomescape.reservation.dto.response.MyReservationsResponse; +import roomescape.reservation.dto.response.ReservationResponse; +import roomescape.reservation.dto.response.ReservationsResponse; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; +import roomescape.theme.service.ThemeService; + +@Service +@Transactional +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationTimeService reservationTimeService; + private final MemberService memberService; + private final ThemeService themeService; + + public ReservationService( + ReservationRepository reservationRepository, + ReservationTimeService reservationTimeService, + MemberService memberService, + ThemeService themeService + ) { + this.reservationRepository = reservationRepository; + this.reservationTimeService = reservationTimeService; + this.memberService = memberService; + this.themeService = themeService; + } + + @Transactional(readOnly = true) + public ReservationsResponse findAllReservations() { + Specification spec = new ReservationSearchSpecification().confirmed().build(); + List response = findAllReservationByStatus(spec); + + return new ReservationsResponse(response); + } + + @Transactional(readOnly = true) + public ReservationsResponse findAllWaiting() { + Specification spec = new ReservationSearchSpecification().waiting().build(); + List response = findAllReservationByStatus(spec); + + return new ReservationsResponse(response); + } + + private List findAllReservationByStatus(Specification spec) { + return reservationRepository.findAll(spec) + .stream() + .map(ReservationResponse::from) + .toList(); + } + + public void removeReservationById(Long reservationId, Long memberId) { + validateIsMemberAdmin(memberId); + reservationRepository.deleteById(reservationId); + } + + public Reservation addReservation(ReservationRequest request, Long memberId) { + validateIsReservationExist(request.themeId(), request.timeId(), request.date()); + Reservation reservation = getReservationForSave(request.timeId(), request.themeId(), request.date(), memberId, + ReservationStatus.CONFIRMED); + return reservationRepository.save(reservation); + } + + public ReservationResponse addReservationByAdmin(AdminReservationRequest request) { + validateIsReservationExist(request.themeId(), request.timeId(), request.date()); + return addReservationWithoutPayment(request.themeId(), request.timeId(), request.date(), + request.memberId(), ReservationStatus.CONFIRMED_PAYMENT_REQUIRED); + } + + public ReservationResponse addWaiting(WaitingRequest request, Long memberId) { + validateMemberAlreadyReserve(request.themeId(), request.timeId(), request.date(), memberId); + return addReservationWithoutPayment(request.themeId(), request.timeId(), request.date(), memberId, + ReservationStatus.WAITING); + } + + private ReservationResponse addReservationWithoutPayment(Long themeId, Long timeId, LocalDate date, Long memberId, + ReservationStatus status) { + Reservation reservation = getReservationForSave(timeId, themeId, date, memberId, status); + Reservation saved = reservationRepository.save(reservation); + return ReservationResponse.from(saved); + } + + private void validateMemberAlreadyReserve(Long themeId, Long timeId, LocalDate date, Long memberId) { + Specification spec = new ReservationSearchSpecification() + .sameMemberId(memberId) + .sameThemeId(themeId) + .sameTimeId(timeId) + .sameDate(date) + .build(); + + if (reservationRepository.exists(spec)) { + throw new RoomEscapeException(ErrorType.HAS_RESERVATION_OR_WAITING, HttpStatus.BAD_REQUEST); + } + } + + private void validateIsReservationExist(Long themeId, Long timeId, LocalDate date) { + Specification spec = new ReservationSearchSpecification() + .confirmed() + .sameThemeId(themeId) + .sameTimeId(timeId) + .sameDate(date) + .build(); + + if (reservationRepository.exists(spec)) { + throw new RoomEscapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT); + } + } + + private void validateDateAndTime( + LocalDate requestDate, + ReservationTime requestReservationTime + ) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime request = LocalDateTime.of(requestDate, requestReservationTime.getStartAt()); + if (request.isBefore(now)) { + throw new RoomEscapeException(ErrorType.RESERVATION_PERIOD_IN_PAST, + String.format("[now: %s %s | request: %s %s]", + now.toLocalDate(), now.toLocalTime(), requestDate, requestReservationTime.getStartAt()), + HttpStatus.BAD_REQUEST + ); + } + } + + private Reservation getReservationForSave(Long timeId, Long themeId, LocalDate date, Long memberId, + ReservationStatus status) { + ReservationTime time = reservationTimeService.findTimeById(timeId); + Theme theme = themeService.findThemeById(themeId); + Member member = memberService.findMemberById(memberId); + + validateDateAndTime(date, time); + return new Reservation(date, time, theme, member, status); + } + + @Transactional(readOnly = true) + public ReservationsResponse findFilteredReservations(Long themeId, Long memberId, LocalDate dateFrom, + LocalDate dateTo) { + validateDateForSearch(dateFrom, dateTo); + Specification spec = new ReservationSearchSpecification() + .confirmed() + .sameThemeId(themeId) + .sameMemberId(memberId) + .dateStartFrom(dateFrom) + .dateEndAt(dateTo) + .build(); + + List response = reservationRepository.findAll(spec) + .stream() + .map(ReservationResponse::from) + .toList(); + + return new ReservationsResponse(response); + } + + private void validateDateForSearch(LocalDate startFrom, LocalDate endAt) { + if (startFrom == null || endAt == null) { + return; + } + if (startFrom.isAfter(endAt)) { + throw new RoomEscapeException(ErrorType.INVALID_DATE_RANGE, + String.format("[startFrom: %s, endAt: %s", startFrom, endAt), HttpStatus.BAD_REQUEST); + } + } + + @Transactional(readOnly = true) + public MyReservationsResponse findMemberReservations(Long memberId) { + return new MyReservationsResponse(reservationRepository.findMyReservations(memberId)); + } + + public void approveWaiting(Long reservationId, Long memberId) { + validateIsMemberAdmin(memberId); + if (reservationRepository.isExistConfirmedReservation(reservationId)) { + throw new RoomEscapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT); + } + reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED); + } + + public void cancelWaiting(Long reservationId, Long memberId) { + Reservation waiting = reservationRepository.findById(reservationId) + .filter(Reservation::isWaiting) + .filter(r -> r.isSameMember(memberId)) + .orElseThrow(() -> throwReservationNotFound(reservationId)); + reservationRepository.delete(waiting); + } + + public void denyWaiting(Long reservationId, Long memberId) { + validateIsMemberAdmin(memberId); + Reservation waiting = reservationRepository.findById(reservationId) + .filter(Reservation::isWaiting) + .orElseThrow(() -> throwReservationNotFound(reservationId)); + reservationRepository.delete(waiting); + } + + private void validateIsMemberAdmin(Long memberId) { + Member member = memberService.findMemberById(memberId); + if (member.isAdmin()) { + return; + } + throw new RoomEscapeException(ErrorType.PERMISSION_DOES_NOT_EXIST, HttpStatus.FORBIDDEN); + } + + private RoomEscapeException throwReservationNotFound(Long reservationId) { + return new RoomEscapeException(ErrorType.RESERVATION_NOT_FOUND, + String.format("[reservationId: %d]", reservationId), HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/roomescape/reservation/service/ReservationTimeService.java b/src/main/java/roomescape/reservation/service/ReservationTimeService.java new file mode 100644 index 00000000..3dd3342e --- /dev/null +++ b/src/main/java/roomescape/reservation/service/ReservationTimeService.java @@ -0,0 +1,100 @@ +package roomescape.reservation.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.reservation.dto.request.ReservationTimeRequest; +import roomescape.reservation.dto.response.ReservationTimeInfoResponse; +import roomescape.reservation.dto.response.ReservationTimeInfosResponse; +import roomescape.reservation.dto.response.ReservationTimeResponse; +import roomescape.reservation.dto.response.ReservationTimesResponse; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Service +@Transactional +public class ReservationTimeService { + + private final ReservationTimeRepository reservationTimeRepository; + private final ReservationRepository reservationRepository; + + public ReservationTimeService( + ReservationTimeRepository reservationTimeRepository, + ReservationRepository reservationRepository + ) { + this.reservationTimeRepository = reservationTimeRepository; + this.reservationRepository = reservationRepository; + } + + @Transactional(readOnly = true) + public ReservationTime findTimeById(Long id) { + return reservationTimeRepository.findById(id) + .orElseThrow(() -> new RoomEscapeException(ErrorType.RESERVATION_TIME_NOT_FOUND, + String.format("[reservationTimeId: %d]", id), HttpStatus.BAD_REQUEST)); + } + + @Transactional(readOnly = true) + public ReservationTimesResponse findAllTimes() { + List response = reservationTimeRepository.findAll() + .stream() + .map(ReservationTimeResponse::from) + .toList(); + + return new ReservationTimesResponse(response); + } + + public ReservationTimeResponse addTime(ReservationTimeRequest reservationTimeRequest) { + validateTimeDuplication(reservationTimeRequest); + ReservationTime reservationTime = reservationTimeRepository.save(reservationTimeRequest.toTime()); + + return ReservationTimeResponse.from(reservationTime); + } + + private void validateTimeDuplication(ReservationTimeRequest reservationTimeRequest) { + List duplicateReservationTimes = reservationTimeRepository.findByStartAt( + reservationTimeRequest.startAt()); + + if (!duplicateReservationTimes.isEmpty()) { + throw new RoomEscapeException(ErrorType.TIME_DUPLICATED, + String.format("[startAt: %s]", reservationTimeRequest.startAt()), HttpStatus.CONFLICT); + } + } + + public void removeTimeById(Long id) { + ReservationTime reservationTime = findTimeById(id); + List usingTimeReservations = reservationRepository.findByReservationTime(reservationTime); + + if (!usingTimeReservations.isEmpty()) { + throw new RoomEscapeException(ErrorType.TIME_IS_USED_CONFLICT, String.format("[timeId: %d]", id), + HttpStatus.CONFLICT); + } + + reservationTimeRepository.deleteById(id); + } + + @Transactional(readOnly = true) + public ReservationTimeInfosResponse findAllAvailableTimesByDateAndTheme(LocalDate date, Long themeId) { + List allTimes = reservationTimeRepository.findAll(); + List reservations = reservationRepository.findByThemeId(themeId); + + List response = allTimes.stream() + .map(time -> new ReservationTimeInfoResponse(time.getId(), time.getStartAt(), + isReservationBooked(reservations, date, time))) + .toList(); + + return new ReservationTimeInfosResponse(response); + } + + private boolean isReservationBooked(List reservations, LocalDate date, ReservationTime time) { + return reservations.stream() + .anyMatch(reservation -> reservation.isSameDateAndTime(date, time)); + } +} diff --git a/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java b/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java new file mode 100644 index 00000000..785182a8 --- /dev/null +++ b/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java @@ -0,0 +1,56 @@ +package roomescape.reservation.service; + +import java.time.OffsetDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.response.PaymentCancelResponse; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.dto.response.ReservationPaymentResponse; +import roomescape.payment.service.PaymentService; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.response.ReservationResponse; + +@Service +@Transactional +public class ReservationWithPaymentService { + + private final ReservationService reservationService; + private final PaymentService paymentService; + + public ReservationWithPaymentService(ReservationService reservationService, + PaymentService paymentService) { + this.reservationService = reservationService; + this.paymentService = paymentService; + } + + public ReservationResponse addReservationWithPayment(ReservationRequest request, PaymentResponse paymentInfo, + Long memberId) { + Reservation reservation = reservationService.addReservation(request, memberId); + ReservationPaymentResponse reservationPaymentResponse = paymentService.savePayment(paymentInfo, reservation); + + return reservationPaymentResponse.reservation(); + } + + public void saveCanceledPayment(PaymentCancelResponse cancelInfo, OffsetDateTime approvedAt, String paymentKey) { + paymentService.saveCanceledPayment(cancelInfo, approvedAt, paymentKey); + } + + public PaymentCancelRequest removeReservationWithPayment(Long reservationId, Long memberId) { + PaymentCancelRequest paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservationId); + reservationService.removeReservationById(reservationId, memberId); + return paymentCancelRequest; + } + + @Transactional(readOnly = true) + public boolean isNotPaidReservation(Long reservationId) { + return paymentService.findPaymentByReservationId(reservationId).isEmpty(); + } + + public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) { + paymentService.updateCanceledTime(paymentKey, canceledAt); + } +} diff --git a/src/main/java/roomescape/system/auth/annotation/Admin.java b/src/main/java/roomescape/system/auth/annotation/Admin.java new file mode 100644 index 00000000..e525ecc6 --- /dev/null +++ b/src/main/java/roomescape/system/auth/annotation/Admin.java @@ -0,0 +1,11 @@ +package roomescape.system.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Admin { +} diff --git a/src/main/java/roomescape/system/auth/annotation/LoginRequired.java b/src/main/java/roomescape/system/auth/annotation/LoginRequired.java new file mode 100644 index 00000000..e2df7c1f --- /dev/null +++ b/src/main/java/roomescape/system/auth/annotation/LoginRequired.java @@ -0,0 +1,11 @@ +package roomescape.system.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginRequired { +} diff --git a/src/main/java/roomescape/system/auth/annotation/MemberId.java b/src/main/java/roomescape/system/auth/annotation/MemberId.java new file mode 100644 index 00000000..208b6ee2 --- /dev/null +++ b/src/main/java/roomescape/system/auth/annotation/MemberId.java @@ -0,0 +1,11 @@ +package roomescape.system.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberId { +} diff --git a/src/main/java/roomescape/system/auth/controller/AuthController.java b/src/main/java/roomescape/system/auth/controller/AuthController.java new file mode 100644 index 00000000..4d2d87e5 --- /dev/null +++ b/src/main/java/roomescape/system/auth/controller/AuthController.java @@ -0,0 +1,102 @@ +package roomescape.system.auth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import roomescape.system.auth.annotation.LoginRequired; +import roomescape.system.auth.annotation.MemberId; +import roomescape.system.auth.dto.LoginCheckResponse; +import roomescape.system.auth.dto.LoginRequest; +import roomescape.system.auth.jwt.dto.TokenDto; +import roomescape.system.auth.service.AuthService; +import roomescape.system.dto.response.ErrorResponse; +import roomescape.system.dto.response.RoomEscapeApiResponse; + +@RestController +@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/login") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "로그인") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공시 쿠키에 토큰 정보를 저장합니다."), + @ApiResponse(responseCode = "400", description = "존재하지 않는 회원이거나, 이메일 또는 비밀번호가 잘못 입력되었습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse login( + @Valid @RequestBody LoginRequest loginRequest, + HttpServletResponse response + ) { + TokenDto tokenInfo = authService.login(loginRequest); + addCookieToResponse(new Cookie("accessToken", tokenInfo.accessToken()), response); + return RoomEscapeApiResponse.success(); + } + + @GetMapping("/login/check") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "로그인 상태 확인") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 상태이며, 로그인된 회원의 이름을 반환합니다."), + @ApiResponse(responseCode = "400", description = "쿠키에 있는 토큰 정보로 회원을 조회할 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + public RoomEscapeApiResponse checkLogin(@MemberId @Parameter(hidden = true) Long memberId) { + LoginCheckResponse response = authService.checkLogin(memberId); + return RoomEscapeApiResponse.success(response); + } + + @LoginRequired + @PostMapping("/logout") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "로그아웃", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다.") + }) + public RoomEscapeApiResponse logout( + HttpServletRequest request, + HttpServletResponse response + ) { + Cookie cookie = getTokenCookie(request); + cookie.setValue(null); + cookie.setMaxAge(0); + addCookieToResponse(cookie, response); + return RoomEscapeApiResponse.success(); + } + + private Cookie getTokenCookie(HttpServletRequest request) { + for (Cookie cookie : request.getCookies()) { + if (cookie.getName().equals("accessToken")) { + return cookie; + } + } + return new Cookie("accessToken", null); + } + + private void addCookieToResponse(Cookie cookie, HttpServletResponse response) { + cookie.setHttpOnly(true); + + response.addCookie(cookie); + } +} diff --git a/src/main/java/roomescape/system/auth/dto/LoginCheckResponse.java b/src/main/java/roomescape/system/auth/dto/LoginCheckResponse.java new file mode 100644 index 00000000..957012cb --- /dev/null +++ b/src/main/java/roomescape/system/auth/dto/LoginCheckResponse.java @@ -0,0 +1,9 @@ +package roomescape.system.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "로그인 체크 응답", description = "로그인 상태 체크 응답시 사용됩니다.") +public record LoginCheckResponse( + @Schema(description = "로그인된 회원의 이름") String name +) { +} diff --git a/src/main/java/roomescape/system/auth/dto/LoginRequest.java b/src/main/java/roomescape/system/auth/dto/LoginRequest.java new file mode 100644 index 00000000..72a5125a --- /dev/null +++ b/src/main/java/roomescape/system/auth/dto/LoginRequest.java @@ -0,0 +1,17 @@ +package roomescape.system.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Schema(name = "로그인 요청", description = "로그인 요청 시 사용됩니다.") +public record LoginRequest( + @NotBlank(message = "이메일은 null 또는 공백일 수 없습니다.") + @Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com)") + @Schema(description = "필수 값이며, 이메일 형식으로 입력해야 합니다.", example = "abc123@gmail.com") + String email, + @NotBlank(message = "비밀번호는 null 또는 공백일 수 없습니다.") + @Schema(description = "최소 1글자 이상 입력해야 합니다.") + String password +) { +} diff --git a/src/main/java/roomescape/system/auth/interceptor/AdminInterceptor.java b/src/main/java/roomescape/system/auth/interceptor/AdminInterceptor.java new file mode 100644 index 00000000..4304a37f --- /dev/null +++ b/src/main/java/roomescape/system/auth/interceptor/AdminInterceptor.java @@ -0,0 +1,86 @@ +package roomescape.system.auth.interceptor; + +import java.util.Arrays; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import roomescape.member.domain.Member; +import roomescape.member.service.MemberService; +import roomescape.system.auth.annotation.Admin; +import roomescape.system.auth.jwt.JwtHandler; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Component +public class AdminInterceptor implements HandlerInterceptor { + + private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + private final MemberService memberService; + private final JwtHandler jwtHandler; + + public AdminInterceptor(MemberService memberService, JwtHandler jwtHandler) { + this.memberService = memberService; + this.jwtHandler = jwtHandler; + } + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) + throws Exception { + if (isHandlerIrrelevantWithAdmin(handler)) { + return true; + } + + Member member; + try { + Cookie token = getToken(request); + Long memberId = jwtHandler.getMemberIdFromToken(token.getValue()); + member = memberService.findMemberById(memberId); + } catch (RoomEscapeException e) { + response.sendRedirect("/login"); + throw e; + } + + if (member.isAdmin()) { + return true; + } else { + response.sendRedirect("/login"); + throw new RoomEscapeException(ErrorType.PERMISSION_DOES_NOT_EXIST, + String.format("[memberId: %d, Role: %s]", member.getId(), member.getRole()), HttpStatus.FORBIDDEN); + } + } + + private Cookie getToken(HttpServletRequest request) { + validateCookieHeader(request); + + Cookie[] cookies = request.getCookies(); + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals(ACCESS_TOKEN_COOKIE_NAME)) + .findAny() + .orElseThrow(() -> new RoomEscapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED)); + } + + private void validateCookieHeader(HttpServletRequest request) { + String cookieHeader = request.getHeader("Cookie"); + if (cookieHeader == null) { + throw new RoomEscapeException(ErrorType.NOT_EXIST_COOKIE, HttpStatus.UNAUTHORIZED); + } + } + + private boolean isHandlerIrrelevantWithAdmin(Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + Admin adminAnnotation = handlerMethod.getMethodAnnotation(Admin.class); + return adminAnnotation == null; + } +} diff --git a/src/main/java/roomescape/system/auth/interceptor/LoginInterceptor.java b/src/main/java/roomescape/system/auth/interceptor/LoginInterceptor.java new file mode 100644 index 00000000..2aa0bafc --- /dev/null +++ b/src/main/java/roomescape/system/auth/interceptor/LoginInterceptor.java @@ -0,0 +1,79 @@ +package roomescape.system.auth.interceptor; + +import java.util.Arrays; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import roomescape.member.domain.Member; +import roomescape.member.service.MemberService; +import roomescape.system.auth.annotation.LoginRequired; +import roomescape.system.auth.jwt.JwtHandler; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Component +public class LoginInterceptor implements HandlerInterceptor { + + private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + private final MemberService memberService; + private final JwtHandler jwtHandler; + + public LoginInterceptor(MemberService memberService, JwtHandler jwtHandler) { + this.memberService = memberService; + this.jwtHandler = jwtHandler; + } + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) + throws Exception { + if (isHandlerIrrelevantWithLoginRequired(handler)) { + return true; + } + + Member member; + try { + Cookie token = getToken(request); + Long memberId = jwtHandler.getMemberIdFromToken(token.getValue()); + member = memberService.findMemberById(memberId); + return member != null; + } catch (RoomEscapeException e) { + response.sendRedirect("/login"); + throw new RoomEscapeException(ErrorType.LOGIN_REQUIRED, HttpStatus.FORBIDDEN); + } + } + + private Cookie getToken(HttpServletRequest request) { + validateCookieHeader(request); + + Cookie[] cookies = request.getCookies(); + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals(ACCESS_TOKEN_COOKIE_NAME)) + .findAny() + .orElseThrow(() -> new RoomEscapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED)); + } + + private void validateCookieHeader(HttpServletRequest request) { + String cookieHeader = request.getHeader("Cookie"); + if (cookieHeader == null) { + throw new RoomEscapeException(ErrorType.NOT_EXIST_COOKIE, HttpStatus.UNAUTHORIZED); + } + } + + private boolean isHandlerIrrelevantWithLoginRequired(Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + LoginRequired loginRequiredAnnotation = handlerMethod.getMethodAnnotation(LoginRequired.class); + return loginRequiredAnnotation == null; + } +} \ No newline at end of file diff --git a/src/main/java/roomescape/system/auth/jwt/JwtHandler.java b/src/main/java/roomescape/system/auth/jwt/JwtHandler.java new file mode 100644 index 00000000..d2f24a5f --- /dev/null +++ b/src/main/java/roomescape/system/auth/jwt/JwtHandler.java @@ -0,0 +1,65 @@ +package roomescape.system.auth.jwt; + +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.SignatureException; +import io.jsonwebtoken.UnsupportedJwtException; +import roomescape.system.auth.jwt.dto.TokenDto; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Component +public class JwtHandler { + + @Value("${security.jwt.token.secret-key}") + private String secretKey; + + @Value("${security.jwt.token.access.expire-length}") + private long accessTokenExpireTime; + + public TokenDto createToken(Long memberId) { + Date date = new Date(); + Date accessTokenExpiredAt = new Date(date.getTime() + accessTokenExpireTime); + + String accessToken = Jwts.builder() + .claim("memberId", memberId) + .setIssuedAt(date) + .setExpiration(accessTokenExpiredAt) + .signWith(SignatureAlgorithm.HS256, secretKey.getBytes()) + .compact(); + + return new TokenDto(accessToken); + } + + public Long getMemberIdFromToken(String token) { + validateToken(token); + + return Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token) + .getBody() + .get("memberId", Long.class); + } + + public void validateToken(String token) { + try { + Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token); + } catch (ExpiredJwtException e) { + throw new RoomEscapeException(ErrorType.EXPIRED_TOKEN, HttpStatus.UNAUTHORIZED); + } catch (UnsupportedJwtException e) { + throw new RoomEscapeException(ErrorType.UNSUPPORTED_TOKEN, HttpStatus.UNAUTHORIZED); + } catch (MalformedJwtException e) { + throw new RoomEscapeException(ErrorType.MALFORMED_TOKEN, HttpStatus.UNAUTHORIZED); + } catch (SignatureException e) { + throw new RoomEscapeException(ErrorType.INVALID_SIGNATURE_TOKEN, HttpStatus.UNAUTHORIZED); + } catch (IllegalArgumentException e) { + throw new RoomEscapeException(ErrorType.ILLEGAL_TOKEN, HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/src/main/java/roomescape/system/auth/jwt/dto/TokenDto.java b/src/main/java/roomescape/system/auth/jwt/dto/TokenDto.java new file mode 100644 index 00000000..cd6302de --- /dev/null +++ b/src/main/java/roomescape/system/auth/jwt/dto/TokenDto.java @@ -0,0 +1,4 @@ +package roomescape.system.auth.jwt.dto; + +public record TokenDto(String accessToken) { +} diff --git a/src/main/java/roomescape/system/auth/resolver/MemberIdResolver.java b/src/main/java/roomescape/system/auth/resolver/MemberIdResolver.java new file mode 100644 index 00000000..d72c3d55 --- /dev/null +++ b/src/main/java/roomescape/system/auth/resolver/MemberIdResolver.java @@ -0,0 +1,53 @@ +package roomescape.system.auth.resolver; + +import java.util.Arrays; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import roomescape.system.auth.annotation.MemberId; +import roomescape.system.auth.jwt.JwtHandler; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@Component +public class MemberIdResolver implements HandlerMethodArgumentResolver { + + private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + + private final JwtHandler jwtHandler; + + public MemberIdResolver(JwtHandler jwtHandler) { + this.jwtHandler = jwtHandler; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(MemberId.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) throws Exception { + Cookie[] cookies = webRequest.getNativeRequest(HttpServletRequest.class).getCookies(); + if (cookies == null) { + throw new RoomEscapeException(ErrorType.NOT_EXIST_COOKIE, HttpStatus.UNAUTHORIZED); + } + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals(ACCESS_TOKEN_COOKIE_NAME)) + .findAny() + .map(cookie -> jwtHandler.getMemberIdFromToken(cookie.getValue())) + .orElseThrow(() -> new RoomEscapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED)); + } +} diff --git a/src/main/java/roomescape/system/auth/service/AuthService.java b/src/main/java/roomescape/system/auth/service/AuthService.java new file mode 100644 index 00000000..e8d43828 --- /dev/null +++ b/src/main/java/roomescape/system/auth/service/AuthService.java @@ -0,0 +1,34 @@ +package roomescape.system.auth.service; + +import org.springframework.stereotype.Service; + +import roomescape.member.domain.Member; +import roomescape.member.service.MemberService; +import roomescape.system.auth.dto.LoginCheckResponse; +import roomescape.system.auth.dto.LoginRequest; +import roomescape.system.auth.jwt.JwtHandler; +import roomescape.system.auth.jwt.dto.TokenDto; + +@Service +public class AuthService { + + private final MemberService memberService; + private final JwtHandler jwtHandler; + + public AuthService(MemberService memberService, JwtHandler jwtHandler) { + this.memberService = memberService; + this.jwtHandler = jwtHandler; + } + + public TokenDto login(LoginRequest request) { + Member member = memberService.findMemberByEmailAndPassword(request.email(), request.password()); + + return jwtHandler.createToken(member.getId()); + } + + public LoginCheckResponse checkLogin(Long memberId) { + Member member = memberService.findMemberById(memberId); + + return new LoginCheckResponse(member.getName()); + } +} diff --git a/src/main/java/roomescape/system/config/JacksonConfig.java b/src/main/java/roomescape/system/config/JacksonConfig.java new file mode 100644 index 00000000..6753e4e2 --- /dev/null +++ b/src/main/java/roomescape/system/config/JacksonConfig.java @@ -0,0 +1,40 @@ +package roomescape.system.config; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(javaTimeModule()); + return objectMapper; + } + + @Bean + public JavaTimeModule javaTimeModule() { + JavaTimeModule javaTimeModule = new JavaTimeModule(); + + javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)); + javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)); + + javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm"))); + javaTimeModule.addDeserializer(LocalTime.class, + new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm"))); + + return javaTimeModule; + } +} diff --git a/src/main/java/roomescape/system/config/SwaggerConfig.java b/src/main/java/roomescape/system/config/SwaggerConfig.java new file mode 100644 index 00000000..0045f4fb --- /dev/null +++ b/src/main/java/roomescape/system/config/SwaggerConfig.java @@ -0,0 +1,76 @@ +package roomescape.system.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI().info(apiInfo()); + } + + private Info apiInfo() { + return new Info() + .title("방탈출 예약 API 문서") + .description(""" + ## API 테스트는 '1. 인증 / 인가 API' 의 '/login' 을 통해 로그인 후 사용해주세요. + + ### 테스트시 로그인 가능한 계정 정보 + + - 아래의 JSON 형태의 데이터를 그대로 복사한 뒤 'POST /login' 의 Request Body 에 넣어서 사용해주세요. + + - **관리자**: + { + "email": "a@a.a", + "password": "a" + } + + + - **회원**: + + - 1번 회원 + { + "email": "1@1.1", + "password": "1" + } + + - 2번 회원 + { + "email": "2@2.2", + "password": "2" + } + + - 3번 회원 + { + "email": "3@3.3", + "password": "3" + } + + - 4번 회원 + { + "email": "4@4.4", + "password": "4" + } + + ### 테스트시 사용할 수 있는 파라미터 정보 + - **themeId**: 1(테스트1), 2(테스트2), 3(테스트3), 4(테스트4) + + - **timeId**: 1(15:00), 2(16:00), 3(17:00), 4(18:00) + + - **memberId**: 1(어드민), 2(회원1), 3(회원2), 4(회원3), 5(회원4) + + - **reservationId**: + - 1 ~ 6: 예약 및 결제 완료 상태 + + - 7: 예약은 승인되었으나, 결제 대기 상태 + + - 8 ~ 10: 예약 대기 상태 + """) + .version("1.0.0"); + } +} diff --git a/src/main/java/roomescape/system/config/WebMvcConfig.java b/src/main/java/roomescape/system/config/WebMvcConfig.java new file mode 100644 index 00000000..000349bc --- /dev/null +++ b/src/main/java/roomescape/system/config/WebMvcConfig.java @@ -0,0 +1,38 @@ +package roomescape.system.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import roomescape.system.auth.interceptor.AdminInterceptor; +import roomescape.system.auth.interceptor.LoginInterceptor; +import roomescape.system.auth.resolver.MemberIdResolver; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final MemberIdResolver memberIdResolver; + private final AdminInterceptor adminInterceptor; + private final LoginInterceptor loginInterceptor; + + public WebMvcConfig(MemberIdResolver memberIdResolver, AdminInterceptor adminInterceptor, + LoginInterceptor loginInterceptor) { + this.memberIdResolver = memberIdResolver; + this.adminInterceptor = adminInterceptor; + this.loginInterceptor = loginInterceptor; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(memberIdResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor); + registry.addInterceptor(loginInterceptor); + } +} diff --git a/src/main/java/roomescape/system/dto/response/ErrorResponse.java b/src/main/java/roomescape/system/dto/response/ErrorResponse.java new file mode 100644 index 00000000..a5c4a638 --- /dev/null +++ b/src/main/java/roomescape/system/dto/response/ErrorResponse.java @@ -0,0 +1,15 @@ +package roomescape.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import roomescape.system.exception.ErrorType; + +@Schema(name = "예외 응답", description = "예외 발생 시 응답에 사용됩니다.") +public record ErrorResponse( + @Schema(description = "발생한 예외의 종류", example = "INVALID_REQUEST_DATA") ErrorType errorType, + @Schema(description = "예외 메시지", example = "요청 데이터 값이 올바르지 않습니다.") String message +) { + + public static ErrorResponse of(ErrorType errorType, String message) { + return new ErrorResponse(errorType, message); + } +} diff --git a/src/main/java/roomescape/system/dto/response/RoomEscapeApiResponse.java b/src/main/java/roomescape/system/dto/response/RoomEscapeApiResponse.java new file mode 100644 index 00000000..31628de3 --- /dev/null +++ b/src/main/java/roomescape/system/dto/response/RoomEscapeApiResponse.java @@ -0,0 +1,20 @@ +package roomescape.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "API 응답 시에 사용합니다.") +public record RoomEscapeApiResponse( + @Schema(description = "응답 메시지", defaultValue = SUCCESS_MESSAGE) String message, + @Schema(description = "응답 바디") T data +) { + + private static final String SUCCESS_MESSAGE = "요청이 성공적으로 수행되었습니다."; + + public static RoomEscapeApiResponse success(T data) { + return new RoomEscapeApiResponse<>(SUCCESS_MESSAGE, data); + } + + public static RoomEscapeApiResponse success() { + return new RoomEscapeApiResponse<>(SUCCESS_MESSAGE, null); + } +} diff --git a/src/main/java/roomescape/system/exception/ErrorType.java b/src/main/java/roomescape/system/exception/ErrorType.java new file mode 100644 index 00000000..ab026a3f --- /dev/null +++ b/src/main/java/roomescape/system/exception/ErrorType.java @@ -0,0 +1,60 @@ +package roomescape.system.exception; + +public enum ErrorType { + + // 400 Bad Request + REQUEST_DATA_BLANK("요청 데이터에 유효하지 않은 값(null OR 공백)이 포함되어있습니다."), + INVALID_REQUEST_DATA_TYPE("요청 데이터 형식이 올바르지 않습니다."), + INVALID_REQUEST_DATA("요청 데이터 값이 올바르지 않습니다."), + INVALID_DATE_RANGE("종료 날짜는 시작 날짜 이전일 수 없습니다."), + HAS_RESERVATION_OR_WAITING("같은 테마에 대한 예약(대기)는 한 번만 가능합니다."), + + // 401 Unauthorized + EXPIRED_TOKEN("토큰이 만료되었습니다. 다시 로그인 해주세요."), + UNSUPPORTED_TOKEN("지원하지 않는 JWT 토큰입니다."), + MALFORMED_TOKEN("형식이 맞지 않는 JWT 토큰입니다."), + INVALID_SIGNATURE_TOKEN("잘못된 JWT 토큰 Signature 입니다."), + ILLEGAL_TOKEN("JWT 토큰의 Claim 이 비어있습니다."), + INVALID_TOKEN("JWT 토큰이 존재하지 않거나 유효하지 않습니다."), + NOT_EXIST_COOKIE("쿠키가 존재하지 않습니다. 로그인이 필요한 서비스입니다."), + + // 403 Forbidden + LOGIN_REQUIRED("로그인이 필요한 서비스입니다."), + PERMISSION_DOES_NOT_EXIST("접근 권한이 존재하지 않습니다."), + + // 404 Not Found + MEMBER_NOT_FOUND("회원(Member) 정보가 존재하지 않습니다."), + RESERVATION_NOT_FOUND("예약(Reservation) 정보가 존재하지 않습니다."), + RESERVATION_TIME_NOT_FOUND("예약 시간(ReservationTime) 정보가 존재하지 않습니다."), + THEME_NOT_FOUND("테마(Theme) 정보가 존재하지 않습니다."), + PAYMENT_NOT_POUND("결제(Payment) 정보가 존재하지 않습니다."), + + // 405 Method Not Allowed + METHOD_NOT_ALLOWED("지원하지 않는 HTTP Method 입니다."), + + // 409 Conflict + TIME_IS_USED_CONFLICT("삭제할 수 없는 시간대입니다. 예약이 존재하는지 확인해주세요."), + THEME_IS_USED_CONFLICT("삭제할 수 없는 테마입니다. 예약이 존재하는지 확인해주세요."), + TIME_DUPLICATED("이미 해당 시간이 존재합니다."), + THEME_DUPLICATED("같은 이름의 테마가 존재합니다."), + RESERVATION_DUPLICATED("해당 시간에 이미 예약이 존재합니다."), + RESERVATION_PERIOD_IN_PAST("이미 지난 시간대는 예약할 수 없습니다."), + CANCELED_BEFORE_PAYMENT("취소 시간이 결제 시간 이전일 수 없습니다."), + + // 500 Internal Server Error, + INTERNAL_SERVER_ERROR("서버 내부에서 에러가 발생하였습니다."), + + // Payment Error + PAYMENT_ERROR("결제(취소)에 실패했습니다. 결제(취소) 정보를 확인해주세요."), + PAYMENT_SERVER_ERROR("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요."); + + private final String description; + + ErrorType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/roomescape/system/exception/ExceptionControllerAdvice.java b/src/main/java/roomescape/system/exception/ExceptionControllerAdvice.java new file mode 100644 index 00000000..d6a2c375 --- /dev/null +++ b/src/main/java/roomescape/system/exception/ExceptionControllerAdvice.java @@ -0,0 +1,71 @@ +package roomescape.system.exception; + +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.ResourceAccessException; + +import jakarta.servlet.http.HttpServletResponse; +import roomescape.system.dto.response.ErrorResponse; + +@RestControllerAdvice +public class ExceptionControllerAdvice { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @ExceptionHandler(value = {RoomEscapeException.class}) + public ErrorResponse handleRoomEscapeException(RoomEscapeException e, HttpServletResponse response) { + logger.error("{}{}", e.getMessage(), e.getInvalidValue().orElse(""), e); + response.setStatus(e.getHttpStatus().value()); + return ErrorResponse.of(e.getErrorType(), e.getMessage()); + } + + @ExceptionHandler(ResourceAccessException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleResourceAccessException(ResourceAccessException e) { + logger.error(e.getMessage(), e); + return ErrorResponse.of(ErrorType.PAYMENT_SERVER_ERROR, ErrorType.PAYMENT_SERVER_ERROR.getDescription()); + } + + @ExceptionHandler(value = HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + logger.error(e.getMessage(), e); + return ErrorResponse.of(ErrorType.INVALID_REQUEST_DATA_TYPE, + ErrorType.INVALID_REQUEST_DATA_TYPE.getDescription()); + } + + @ExceptionHandler(value = MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String messages = e.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + logger.error(messages, e); + return ErrorResponse.of(ErrorType.INVALID_REQUEST_DATA, messages); + } + + @ExceptionHandler(value = HttpRequestMethodNotSupportedException.class) + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public ErrorResponse handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + logger.error(e.getMessage(), e); + return ErrorResponse.of(ErrorType.METHOD_NOT_ALLOWED, ErrorType.METHOD_NOT_ALLOWED.getDescription()); + } + + @ExceptionHandler(value = Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleException(Exception e) { + logger.error(e.getMessage(), e); + return ErrorResponse.of(ErrorType.INTERNAL_SERVER_ERROR, ErrorType.INTERNAL_SERVER_ERROR.getDescription()); + } +} diff --git a/src/main/java/roomescape/system/exception/RoomEscapeException.java b/src/main/java/roomescape/system/exception/RoomEscapeException.java new file mode 100644 index 00000000..2f00a8e5 --- /dev/null +++ b/src/main/java/roomescape/system/exception/RoomEscapeException.java @@ -0,0 +1,41 @@ +package roomescape.system.exception; + +import java.util.Optional; + +import org.springframework.http.HttpStatusCode; + +public class RoomEscapeException extends RuntimeException { + + private final ErrorType errorType; + private final String message; + private final String invalidValue; + private final HttpStatusCode httpStatus; + + public RoomEscapeException(ErrorType errorType, HttpStatusCode httpStatus) { + this(errorType, null, httpStatus); + } + + public RoomEscapeException(ErrorType errorType, String invalidValue, HttpStatusCode httpStatus) { + this.errorType = errorType; + this.message = errorType.getDescription(); + this.invalidValue = invalidValue; + this.httpStatus = httpStatus; + } + + public ErrorType getErrorType() { + return errorType; + } + + public HttpStatusCode getHttpStatus() { + return httpStatus; + } + + public Optional getInvalidValue() { + return Optional.ofNullable(invalidValue); + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/roomescape/theme/controller/ThemeController.java b/src/main/java/roomescape/theme/controller/ThemeController.java new file mode 100644 index 00000000..d57f9da4 --- /dev/null +++ b/src/main/java/roomescape/theme/controller/ThemeController.java @@ -0,0 +1,101 @@ +package roomescape.theme.controller; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import roomescape.system.auth.annotation.Admin; +import roomescape.system.auth.annotation.LoginRequired; +import roomescape.system.dto.response.ErrorResponse; +import roomescape.system.dto.response.RoomEscapeApiResponse; +import roomescape.theme.dto.ThemeRequest; +import roomescape.theme.dto.ThemeResponse; +import roomescape.theme.dto.ThemesResponse; +import roomescape.theme.service.ThemeService; + +@RestController +@Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.") +public class ThemeController { + + private final ThemeService themeService; + + public ThemeController(ThemeService themeService) { + this.themeService = themeService; + } + + @LoginRequired + @GetMapping("/themes") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = "로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getAllThemes() { + return RoomEscapeApiResponse.success(themeService.findAllThemes()); + } + + @GetMapping("/themes/most-reserved-last-week") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "가장 많이 예약된 테마 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) + }) + public RoomEscapeApiResponse getMostReservedThemes( + @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") int count + ) { + return RoomEscapeApiResponse.success(themeService.getMostReservedThemesByCount(count)); + } + + @Admin + @PostMapping("/themes") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "테마 추가", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "409", description = "같은 이름의 테마를 추가할 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse saveTheme( + @Valid @RequestBody ThemeRequest request, + HttpServletResponse response + ) { + ThemeResponse themeResponse = themeService.addTheme(request); + response.setHeader(HttpHeaders.LOCATION, "/themes/" + themeResponse.id()); + + return RoomEscapeApiResponse.success(themeResponse); + } + + @Admin + @DeleteMapping("/themes/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "테마 삭제", tags = "관리자 로그인이 필요한 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "409", description = "예약된 테마는 삭제할 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public RoomEscapeApiResponse removeTheme( + @NotNull(message = "themeId는 null일 수 없습니다.") @PathVariable Long id + ) { + themeService.removeThemeById(id); + + return RoomEscapeApiResponse.success(); + } +} diff --git a/src/main/java/roomescape/theme/domain/Theme.java b/src/main/java/roomescape/theme/domain/Theme.java new file mode 100644 index 00000000..19513cd9 --- /dev/null +++ b/src/main/java/roomescape/theme/domain/Theme.java @@ -0,0 +1,65 @@ +package roomescape.theme.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class Theme { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private String description; + + private String thumbnail; + + protected Theme() { + } + + public Theme(String name, String description, String thumbnail) { + this(null, name, description, thumbnail); + } + + public Theme( + Long id, + String name, + String description, + String thumbnail + ) { + this.id = id; + this.name = name; + this.description = description; + this.thumbnail = thumbnail; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getThumbnail() { + return thumbnail; + } + + @Override + public String toString() { + return "Theme{" + + "id=" + id + + ", name=" + name + + ", description=" + description + + ", thumbnail=" + thumbnail + + '}'; + } +} diff --git a/src/main/java/roomescape/theme/domain/repository/ThemeRepository.java b/src/main/java/roomescape/theme/domain/repository/ThemeRepository.java new file mode 100644 index 00000000..17e5f491 --- /dev/null +++ b/src/main/java/roomescape/theme/domain/repository/ThemeRepository.java @@ -0,0 +1,34 @@ +package roomescape.theme.domain.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import roomescape.theme.domain.Theme; + +public interface ThemeRepository extends JpaRepository { + + @Query(value = """ + SELECT t + FROM Theme t + RIGHT JOIN Reservation r ON t.id = r.theme.id + WHERE r.date BETWEEN :startDate AND :endDate + GROUP BY r.theme.id + ORDER BY COUNT(r.theme.id) DESC, t.id ASC + LIMIT :limit + """) + List findTopNThemeBetweenStartDateAndEndDate(LocalDate startDate, LocalDate endDate, int limit); + + boolean existsByName(String name); + + @Query(value = """ + SELECT EXISTS( + SELECT 1 + FROM Reservation r + WHERE r.theme.id = :id + ) + """) + boolean isReservedTheme(Long id); +} diff --git a/src/main/java/roomescape/theme/dto/ThemeRequest.java b/src/main/java/roomescape/theme/dto/ThemeRequest.java new file mode 100644 index 00000000..438c9645 --- /dev/null +++ b/src/main/java/roomescape/theme/dto/ThemeRequest.java @@ -0,0 +1,21 @@ +package roomescape.theme.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(name = "테마 저장 요청", description = "테마 정보를 저장할 때 사용합니다.") +public record ThemeRequest( + @NotBlank(message = "테마의 이름은 null 또는 공백일 수 없습니다.") + @Size(min = 1, max = 20, message = "테마의 이름은 1~20글자 사이여야 합니다.") + @Schema(description = "필수 값이며, 최대 20글자까지 입력 가능합니다.") + String name, + @NotBlank(message = "테마의 설명은 null 또는 공백일 수 없습니다.") + @Size(min = 1, max = 100, message = "테마의 설명은 1~100글자 사이여야 합니다.") + @Schema(description = "필수 값이며, 최대 100글자까지 입력 가능합니다.") + String description, + @NotBlank(message = "테마의 쌈네일은 null 또는 공백일 수 없습니다.") + @Schema(description = "필수 값이며, 썸네일 이미지 URL 을 입력해주세요.") + String thumbnail +) { +} diff --git a/src/main/java/roomescape/theme/dto/ThemeResponse.java b/src/main/java/roomescape/theme/dto/ThemeResponse.java new file mode 100644 index 00000000..f95ab31b --- /dev/null +++ b/src/main/java/roomescape/theme/dto/ThemeResponse.java @@ -0,0 +1,21 @@ +package roomescape.theme.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import roomescape.theme.domain.Theme; + +@Schema(name = "테마 정보", description = "테마 추가 및 조회 응답에 사용됩니다.") +public record ThemeResponse( + @Schema(description = "테마 번호. 테마를 식별할 때 사용합니다.") + Long id, + @Schema(description = "테마 이름. 중복을 허용하지 않습니다.") + String name, + @Schema(description = "테마 설명") + String description, + @Schema(description = "테마 썸네일 이미지 URL") + String thumbnail +) { + + public static ThemeResponse from(Theme theme) { + return new ThemeResponse(theme.getId(), theme.getName(), theme.getDescription(), theme.getThumbnail()); + } +} diff --git a/src/main/java/roomescape/theme/dto/ThemesResponse.java b/src/main/java/roomescape/theme/dto/ThemesResponse.java new file mode 100644 index 00000000..2ea88862 --- /dev/null +++ b/src/main/java/roomescape/theme/dto/ThemesResponse.java @@ -0,0 +1,11 @@ +package roomescape.theme.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "테마 목록 조회 응답", description = "모든 테마 목록 조회 응답시 사용됩니다.") +public record ThemesResponse( + @Schema(description = "모든 테마 목록") List themes +) { +} diff --git a/src/main/java/roomescape/theme/service/ThemeService.java b/src/main/java/roomescape/theme/service/ThemeService.java new file mode 100644 index 00000000..d15bf703 --- /dev/null +++ b/src/main/java/roomescape/theme/service/ThemeService.java @@ -0,0 +1,84 @@ +package roomescape.theme.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; +import roomescape.theme.dto.ThemeRequest; +import roomescape.theme.dto.ThemeResponse; +import roomescape.theme.dto.ThemesResponse; + +@Service +@Transactional +public class ThemeService { + + private final ThemeRepository themeRepository; + private final ReservationRepository reservationRepository; + + public ThemeService(ThemeRepository themeRepository, ReservationRepository reservationRepository) { + this.themeRepository = themeRepository; + this.reservationRepository = reservationRepository; + } + + @Transactional(readOnly = true) + public Theme findThemeById(Long id) { + return themeRepository.findById(id) + .orElseThrow(() -> new RoomEscapeException(ErrorType.THEME_NOT_FOUND, + String.format("[themeId: %d]", id), HttpStatus.BAD_REQUEST)); + } + + @Transactional(readOnly = true) + public ThemesResponse findAllThemes() { + List response = themeRepository.findAll() + .stream() + .map(ThemeResponse::from) + .toList(); + + return new ThemesResponse(response); + } + + @Transactional(readOnly = true) + public ThemesResponse getMostReservedThemesByCount(int count) { + LocalDate today = LocalDate.now(); + LocalDate startDate = today.minusDays(7); + LocalDate endDate = today.minusDays(1); + + List response = themeRepository.findTopNThemeBetweenStartDateAndEndDate(startDate, endDate, + count) + .stream() + .map(ThemeResponse::from) + .toList(); + + return new ThemesResponse(response); + } + + public ThemeResponse addTheme(ThemeRequest request) { + validateIsSameThemeNameExist(request.name()); + Theme theme = themeRepository.save(new Theme(request.name(), request.description(), request.thumbnail())); + + return ThemeResponse.from(theme); + } + + private void validateIsSameThemeNameExist(String name) { + if (themeRepository.existsByName(name)) { + throw new RoomEscapeException(ErrorType.THEME_DUPLICATED, + String.format("[name: %s]", name), HttpStatus.CONFLICT); + } + } + + public void removeThemeById(Long id) { + if (themeRepository.isReservedTheme(id)) { + throw new RoomEscapeException(ErrorType.THEME_IS_USED_CONFLICT, + String.format("[themeId: %d]", id), HttpStatus.CONFLICT); + } + themeRepository.deleteById(id); + } +} diff --git a/src/main/java/roomescape/view/controller/AdminPageController.java b/src/main/java/roomescape/view/controller/AdminPageController.java new file mode 100644 index 00000000..61d78f42 --- /dev/null +++ b/src/main/java/roomescape/view/controller/AdminPageController.java @@ -0,0 +1,40 @@ +package roomescape.view.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import roomescape.system.auth.annotation.Admin; + +@Controller +public class AdminPageController { + + @Admin + @GetMapping("/admin") + public String showAdminPage() { + return "admin/index"; + } + + @Admin + @GetMapping("/admin/reservation") + public String showAdminReservationPage() { + return "admin/reservation-new"; + } + + @Admin + @GetMapping("/admin/time") + public String showAdminTimePage() { + return "admin/time"; + } + + @Admin + @GetMapping("/admin/theme") + public String showAdminThemePage() { + return "admin/theme"; + } + + @Admin + @GetMapping("/admin/waiting") + public String showAdminWaitingPage() { + return "admin/waiting"; + } +} diff --git a/src/main/java/roomescape/view/controller/AuthPageController.java b/src/main/java/roomescape/view/controller/AuthPageController.java new file mode 100644 index 00000000..328679ff --- /dev/null +++ b/src/main/java/roomescape/view/controller/AuthPageController.java @@ -0,0 +1,13 @@ +package roomescape.view.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class AuthPageController { + + @GetMapping("/login") + public String showLoginPage() { + return "login"; + } +} diff --git a/src/main/java/roomescape/view/controller/ClientPageController.java b/src/main/java/roomescape/view/controller/ClientPageController.java new file mode 100644 index 00000000..480845b6 --- /dev/null +++ b/src/main/java/roomescape/view/controller/ClientPageController.java @@ -0,0 +1,27 @@ +package roomescape.view.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import roomescape.system.auth.annotation.LoginRequired; + +@Controller +public class ClientPageController { + + @GetMapping("/") + public String showPopularThemePage() { + return "index"; + } + + @LoginRequired + @GetMapping("/reservation") + public String showReservationPage() { + return "reservation"; + } + + @LoginRequired + @GetMapping("/reservation-mine") + public String showReservationMinePage() { + return "reservation-mine"; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 00000000..b704da5f --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,37 @@ +spring: + jpa: + show-sql: false + properties: + hibernate: + format_sql: true + ddl-auto: create-drop + defer-datasource-initialization: true + + h2: + console: + enabled: true + path: /h2-console + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:database + username: sa + password: + +security: + jwt: + token: + secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi + access: + expire-length: 1800000 # 30 분 + +payment: + confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + read-timeout: 3 + connect-timeout: 30 + +springdoc: + swagger-ui: + operationsSorter: method + tagsSorter: alpha + doc-expansion: none + override-with-generic-response: false diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 00000000..01d159e6 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,68 @@ +insert into reservation_time(start_at) +values ('15:00'); +insert into reservation_time(start_at) +values ('16:00'); +insert into reservation_time(start_at) +values ('17:00'); +insert into reservation_time(start_at) +values ('18:00'); + +insert into theme(name, description, thumbnail) +values ('테스트1', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg'); +insert into theme(name, description, thumbnail) +values ('테스트2', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg'); +insert into theme(name, description, thumbnail) +values ('테스트3', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg'); +insert into theme(name, description, thumbnail) +values ('테스트4', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg'); + +insert into member(name, email, password, role) +values ('어드민', 'a@a.a', 'a', 'ADMIN'); +insert into member(name, email, password, role) +values ('1호', '1@1.1', '1', 'MEMBER'); +insert into member(name, email, password, role) +values ('2호', '2@2.2', '2', 'MEMBER'); +insert into member(name, email, password, role) +values ('3호', '3@3.3', '3', 'MEMBER'); +insert into member(name, email, password, role) +values ('4호', '4@4.4', '4', 'MEMBER'); + +-- 예약: 결제 완료 +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (1, DATEADD('DAY', -1, CURRENT_DATE()) - 1, 1, 1, 'CONFIRMED'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (2, DATEADD('DAY', -2, CURRENT_DATE()) - 2, 3, 2, 'CONFIRMED'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (3, DATEADD('DAY', -3, CURRENT_DATE()), 2, 2, 'CONFIRMED'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (4, DATEADD('DAY', -4, CURRENT_DATE()), 1, 2, 'CONFIRMED'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (5, DATEADD('DAY', -5, CURRENT_DATE()), 1, 3, 'CONFIRMED'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (2, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'CONFIRMED'); + +-- 예약: 결제 대기 +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (2, DATEADD('DAY', 8, CURRENT_DATE()), 2, 4, 'CONFIRMED_PAYMENT_REQUIRED'); + +-- 예약 대기 +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (3, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (4, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING'); +insert into reservation(member_id, date, time_id, theme_id, reservation_status) +values (5, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING'); + +-- 결제 정보 +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-1', 'paymentKey-1', 10000, 1, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-2', 'paymentKey-2', 20000, 2, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-3', 'paymentKey-3', 30000, 3, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-4', 'paymentKey-4', 40000, 4, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-5', 'paymentKey-5', 50000, 5, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-6', 'paymentKey-6', 60000, 6, CURRENT_DATE); \ No newline at end of file diff --git a/src/main/resources/static/css/reservation.css b/src/main/resources/static/css/reservation.css new file mode 100644 index 00000000..de9666b7 --- /dev/null +++ b/src/main/resources/static/css/reservation.css @@ -0,0 +1,15 @@ +.disabled { + pointer-events: none; + opacity: 0.6; +} + +#theme-slots .theme-slot.active, #time-slots .time-slot.active { + background-color: #0a3711 !important; + color: white; +} + +#time-slots .time-slot.disabled { + background-color: #cccccc; + color: #666666; + cursor: not-allowed; +} diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 00000000..81506574 --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,62 @@ +.profile-image { + height: 30px; + width: 30px; + border-radius: 50%; + margin-right: 5px; /* 이름과의 간격 조정 */ +} + +.nav-item .dropdown-toggle::after { + display: none; /* 드롭다운 화살표 제거 */ +} + +.nav-item { + margin-right: 10px; /* 네비게이션 간격 조정 */ +} + +.content-container { + width: 70%; + margin: 50px auto; +} + +.content-container-title { + text-align: center; + margin-bottom: 30px; +} + +.form-group input { + width: 100%; + padding: 10px; + margin: 10px 0; + border-radius: 5px; + border: 1px solid #ddd; +} + +/* Solid 버튼 */ +.btn-custom { + background-color: #0a3711; /* 버튼 기본 배경색 */ + color: white; /* 버튼 텍스트 색상 */ + border: 1px solid #0a3711; /* 테두리 색상 일치 */ +} + +.btn-custom:hover { + background-color: #083d0f; /* 호버 상태에서의 배경색 */ + color: white; /* 호버 상태에서의 텍스트 색상 */ + border: 1px solid #083d0f; /* 호버 상태에서의 테두리 색상 */ +} + +/* Outline 버튼 */ +.btn-outline-custom { + background-color: transparent; /* 버튼 기본 배경색 투명 */ + color: #0a3711; /* 버튼 텍스트 색상 */ + border: 1px solid #0a3711; /* 테두리 색상 */ +} + +.btn-outline-custom:hover { + background-color: #0a3711; /* 호버 상태에서의 배경색 */ + color: white; /* 호버 상태에서의 텍스트 색상 */ + border: 1px solid #0a3711; /* 호버 상태에서의 테두리 색상 유지 */ +} + +.cursor-pointer { + cursor: pointer; +} diff --git a/src/main/resources/static/css/toss-style.css b/src/main/resources/static/css/toss-style.css new file mode 100644 index 00000000..a79080d5 --- /dev/null +++ b/src/main/resources/static/css/toss-style.css @@ -0,0 +1,132 @@ +.w-100 { + width: 100%; +} + +.h-100 { + height: 100%; +} + +a { + text-decoration: none; + text-align: center; +} + +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px; + overflow: auto; +} + +.max-w-540 { + max-width: 540px; +} + +.btn-wrapper { + padding: 0 24px; +} + +.btn { + padding: 11px 22px; + border: none; + border-radius: 8px; + + background-color: #f2f4f6; + color: #4e5968; + font-weight: 600; + font-size: 17px; + cursor: pointer; +} + +.btn.primary { + background-color: #3282f6; + color: #f9fcff; +} + +.text-center { + text-align: center; +} + +.flex { + display: flex; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.align-center { + align-items: center; +} + +.confirm-loading { + margin-top: 72px; + height: 400px; + justify-content: space-between; +} + + +.confirm-success { + display: none; + margin-top: 72px; +} + +.button-group { + margin-top: 32px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 16px; +} + +.title { + margin-top: 32px; + margin-bottom: 0; + color: #191f28; + font-weight: bold; + font-size: 24px; +} + +.description { + margin-top: 8px; + color: #4e5968; + font-size: 17px; + font-weight: 500; +} + +.response-section { + margin-top: 60px; + display: flex; + flex-direction: column; + gap: 16px; + font-size: 20px; +} + +.response-section .response-label { + font-weight: 600; + color: #333d48; + font-size: 17px; +} + +.response-section .response-text { + font-weight: 500; + color: #4e5968; + font-size: 17px; + padding-left: 16px; + word-break: break-word; + text-align: right; +} + +.color-grey { + color: #b0b8c1; +} \ No newline at end of file diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 00000000..79a18a8f Binary files /dev/null and b/src/main/resources/static/favicon.ico differ diff --git a/src/main/resources/static/image/admin-logo.png b/src/main/resources/static/image/admin-logo.png new file mode 100644 index 00000000..b46677ec Binary files /dev/null and b/src/main/resources/static/image/admin-logo.png differ diff --git a/src/main/resources/static/image/default-profile.png b/src/main/resources/static/image/default-profile.png new file mode 100644 index 00000000..12e01edd Binary files /dev/null and b/src/main/resources/static/image/default-profile.png differ diff --git a/src/main/resources/static/js/ranking.js b/src/main/resources/static/js/ranking.js new file mode 100644 index 00000000..33be64bc --- /dev/null +++ b/src/main/resources/static/js/ranking.js @@ -0,0 +1,45 @@ +document.addEventListener('DOMContentLoaded', () => { + requestRead(`/themes/most-reserved-last-week?count=10`) // 인기 테마 목록 조회 API endpoint + .then(render) + .catch(error => console.error('Error fetching times:', error)); +}); + +function formatDate(dateString) { + let date = new Date(dateString); + let year = date.getFullYear(); + let month = (date.getMonth() + 1).toString().padStart(2, '0'); // '04' + let day = date.getDate().toString().padStart(2, '0'); // '28' + + return `${year}-${month}-${day}`; // '2024-04-28' +} + +function render(data) { + const container = document.getElementById('theme-ranking'); + data.data.themes.forEach(theme => { + const name = theme.name; + const thumbnail = theme.thumbnail; + const description = theme.description; + + const htmlContent = ` + ${name} +
+
${name}
+ ${description} +
+ `; + + const div = document.createElement('li'); + div.className = 'media my-4'; + div.innerHTML = htmlContent; + + container.appendChild(div); + }) +} + +function requestRead(endpoint) { + return fetch(endpoint) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} diff --git a/src/main/resources/static/js/reservation-mine.js b/src/main/resources/static/js/reservation-mine.js new file mode 100644 index 00000000..ae17a8af --- /dev/null +++ b/src/main/resources/static/js/reservation-mine.js @@ -0,0 +1,57 @@ +document.addEventListener('DOMContentLoaded', () => { + fetch('/reservations-mine') // 내 예약 목록 조회 API 호출 + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }) + .then(render) + .catch(error => console.error('Error fetching reservations:', error)); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + data.data.myReservationResponses.forEach(item => { + const row = tableBody.insertRow(); + + const theme = item.themeName; + const date = item.date; + const time = item.time; + const status = item.status.includes('CONFIRMED') ? (item.status === 'CONFIRMED' ? '예약' : '예약 - 결제 필요') : item.rank + '번째 예약 대기'; + + row.insertCell(0).textContent = theme; + row.insertCell(1).textContent = date; + row.insertCell(2).textContent = time; + row.insertCell(3).textContent = status; + + if (status.includes('대기')) { // 예약 대기 상태일 때 예약 대기 취소 버튼 추가하는 코드, 상태 값은 변경 가능 + const cancelCell = row.insertCell(4); + const cancelButton = document.createElement('button'); + cancelButton.textContent = '취소'; + cancelButton.className = 'btn btn-danger'; + cancelButton.onclick = function () { + requestDeleteWaiting(item.id).then(() => window.location.reload()); + }; + cancelCell.appendChild(cancelButton); + } else { // 예약 완료 상태일 때 + /* + TODO: [미션4 - 2단계] 내 예약 목록 조회 시, + 예약 완료 상태일 때 결제 정보를 함께 보여주기 + 결제 정보 필드명은 자신의 response 에 맞게 변경하기 + */ + row.insertCell(4).textContent = ''; + row.insertCell(5).textContent = item.paymentKey; + row.insertCell(6).textContent = item.amount; + } + }); +} + +function requestDeleteWaiting(id) { + const endpoint = '/reservations/waiting/' + id; + return fetch(endpoint, { + method: 'DELETE' + }).then(response => { + if (response.status === 204) return; + throw new Error('Delete failed'); + }); +} diff --git a/src/main/resources/static/js/reservation-new.js b/src/main/resources/static/js/reservation-new.js new file mode 100644 index 00000000..869d1031 --- /dev/null +++ b/src/main/resources/static/js/reservation-new.js @@ -0,0 +1,194 @@ +let isEditing = false; +const RESERVATION_API_ENDPOINT = '/reservations'; +const TIME_API_ENDPOINT = '/times'; +const THEME_API_ENDPOINT = '/themes'; +const timesOptions = []; +const themesOptions = []; + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('add-button').addEventListener('click', addInputRow); + + requestRead(RESERVATION_API_ENDPOINT) + .then(render) + .catch(error => console.error('Error fetching reservations:', error)); + + fetchTimes(); + fetchThemes(); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + + data.data.reservations.forEach(item => { + const row = tableBody.insertRow(); + + row.insertCell(0).textContent = item.id; // 예약 id + row.insertCell(1).textContent = item.name; // 예약자명 + row.insertCell(2).textContent = item.theme.name; // 테마명 + row.insertCell(3).textContent = item.date; // 예약 날짜 + row.insertCell(4).textContent = item.time.startAt; // 시작 시간 + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); + }); +} + +function fetchTimes() { + requestRead(TIME_API_ENDPOINT) + .then(data => { + timesOptions.push(...data.data.times); + }) + .catch(error => console.error('Error fetching time:', error)); +} + +function fetchThemes() { + requestRead(THEME_API_ENDPOINT) + .then(data => { + themesOptions.push(...data.data.themes); + }) + .catch(error => console.error('Error fetching theme:', error)); +} + +function createSelect(options, defaultText, selectId, textProperty) { + const select = document.createElement('select'); + select.className = 'form-control'; + select.id = selectId; + + // 기본 옵션 추가 + const defaultOption = document.createElement('option'); + defaultOption.textContent = defaultText; + select.appendChild(defaultOption); + + // 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성 + options.forEach(optionData => { + const option = document.createElement('option'); + option.value = optionData.id; + option.textContent = optionData[textProperty]; // 동적 속성 접근 + select.appendChild(option); + }); + + return select; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function addInputRow() { + if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 + + const tableBody = document.getElementById('table-body'); + const row = tableBody.insertRow(); + isEditing = true; + + const nameInput = createInput('text'); + const dateInput = createInput('date'); + const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt'); + const themeDropdown = createSelect(themesOptions, "테마 선택", 'theme-select', 'name'); + + const cellFieldsToCreate = ['', nameInput, themeDropdown, dateInput, timeDropdown]; + + cellFieldsToCreate.forEach((field, index) => { + const cell = row.insertCell(index); + if (typeof field === 'string') { + cell.textContent = field; + } else { + cell.appendChild(field); + } + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow)); + actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { + row.remove(); + isEditing = false; + })); +} + +function createInput(type) { + const input = document.createElement('input'); + input.type = type; + input.className = 'form-control'; + return input; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function saveRow(event) { + // 이벤트 전파를 막는다 + event.stopPropagation(); + + const row = event.target.parentNode.parentNode; + const nameInput = row.querySelector('input[type="text"]'); + const themeSelect = row.querySelector('#theme-select'); + const dateInput = row.querySelector('input[type="date"]'); + const timeSelect = row.querySelector('#time-select'); + + const reservation = { + name: nameInput.value, + themeId: themeSelect.value, + date: dateInput.value, + timeId: timeSelect.value + }; + + requestCreate(reservation) + .then(() => { + location.reload(); + }) + .catch(error => console.error('Error:', error)); + + isEditing = false; // isEditing 값을 false로 설정 +} + +function deleteRow(event) { + const row = event.target.closest('tr'); + const reservationId = row.cells[0].textContent; + + requestDelete(reservationId) + .then(() => row.remove()) + .catch(error => console.error('Error:', error)); +} + +function requestCreate(reservation) { + const requestOptions = { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(reservation) + }; + + return fetch(RESERVATION_API_ENDPOINT, requestOptions) + .then(response => { + if (response.status === 201) return response.json(); + throw new Error('Create failed'); + }); +} + +function requestDelete(id) { + const requestOptions = { + method: 'DELETE', + }; + + return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions) + .then(response => { + if (response.status !== 204) throw new Error('Delete failed'); + }); +} + +function requestRead(endpoint) { + return fetch(endpoint) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} diff --git a/src/main/resources/static/js/reservation-with-member.js b/src/main/resources/static/js/reservation-with-member.js new file mode 100644 index 00000000..c5d804ec --- /dev/null +++ b/src/main/resources/static/js/reservation-with-member.js @@ -0,0 +1,250 @@ +let isEditing = false; +const RESERVATION_API_ENDPOINT = '/reservations'; +const TIME_API_ENDPOINT = '/times'; +const THEME_API_ENDPOINT = '/themes'; +const MEMBER_API_ENDPOINT = '/members'; +const timesOptions = []; +const themesOptions = []; +const membersOptions = []; + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('add-button').addEventListener('click', addInputRow); + document.getElementById('filter-form').addEventListener('submit', applyFilter); + + requestRead(RESERVATION_API_ENDPOINT) + .then(render) + .catch(error => console.error('Error fetching reservations:', error)); + + fetchTimes(); + fetchThemes(); + fetchMembers(); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + + data.data.reservations.forEach(item => { + const row = tableBody.insertRow(); + const isPaid = item.status === 'CONFIRMED' ? '결제 완료' : '결제 대기'; + + row.insertCell(0).textContent = item.id; // 예약 id + row.insertCell(1).textContent = item.member.name; // 사용자 name + row.insertCell(2).textContent = item.theme.name; // 테마 name + row.insertCell(3).textContent = item.date; // date + row.insertCell(4).textContent = item.time.startAt; // 예약 시간 startAt + row.insertCell(5).textContent = isPaid; // 결제 + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); + }); +} + +function fetchTimes() { + requestRead(TIME_API_ENDPOINT) + .then(data => { + timesOptions.push(...data.data.times); + }) + .catch(error => console.error('Error fetching time:', error)); +} + +function fetchThemes() { + requestRead(THEME_API_ENDPOINT) + .then(data => { + themesOptions.push(...data.data.themes); + populateSelect('theme', themesOptions, 'name'); + }) + .catch(error => console.error('Error fetching theme:', error)); +} + +function fetchMembers() { + requestRead(MEMBER_API_ENDPOINT) + .then(data => { + membersOptions.push(...data.data.members); + populateSelect('member', membersOptions, 'name'); + }) + .catch(error => console.error('Error fetching member:', error)); +} + +function populateSelect(selectId, options, textProperty) { + const select = document.getElementById(selectId); + options.forEach(optionData => { + const option = document.createElement('option'); + option.value = optionData.id; + option.textContent = optionData[textProperty]; + select.appendChild(option); + }); +} + +function createSelect(options, defaultText, selectId, textProperty) { + const select = document.createElement('select'); + select.className = 'form-control'; + select.id = selectId; + + // 기본 옵션 추가 + const defaultOption = document.createElement('option'); + defaultOption.textContent = defaultText; + select.appendChild(defaultOption); + + // 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성 + options.forEach(optionData => { + const option = document.createElement('option'); + option.value = optionData.id; + option.textContent = optionData[textProperty]; // 동적 속성 접근 + select.appendChild(option); + }); + + return select; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function addInputRow() { + if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 + + const tableBody = document.getElementById('table-body'); + const row = tableBody.insertRow(); + isEditing = true; + + const dateInput = createInput('date'); + const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt'); + const themeDropdown = createSelect(themesOptions, "테마 선택", 'theme-select', 'name'); + const memberDropdown = createSelect(membersOptions, "멤버 선택", 'member-select', 'name'); + + const cellFieldsToCreate = ['', memberDropdown, themeDropdown, dateInput, timeDropdown]; + + cellFieldsToCreate.forEach((field, index) => { + const cell = row.insertCell(index); + if (typeof field === 'string') { + cell.textContent = field; + } else { + cell.appendChild(field); + } + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow)); + actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { + row.remove(); + isEditing = false; + })); +} + +function createInput(type) { + const input = document.createElement('input'); + input.type = type; + input.className = 'form-control'; + return input; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function saveRow(event) { + // 이벤트 전파를 막는다 + event.stopPropagation(); + + const row = event.target.parentNode.parentNode; + const dateInput = row.querySelector('input[type="date"]'); + const memberSelect = row.querySelector('#member-select'); + const themeSelect = row.querySelector('#theme-select'); + const timeSelect = row.querySelector('#time-select'); + + const reservation = { + date: dateInput.value, + themeId: themeSelect.value, + timeId: timeSelect.value, + memberId: memberSelect.value, + }; + + requestCreate(reservation) + .then(() => { + location.reload(); + }) + .catch(error => console.error('Error:', error)); + + isEditing = false; // isEditing 값을 false로 설정 +} + +function deleteRow(event) { + const row = event.target.closest('tr'); + const reservationId = row.cells[0].textContent; + + requestDelete(reservationId) + .then(() => row.remove()) + .catch(error => console.error('Error:', error)); +} + +function applyFilter(event) { + event.preventDefault(); + + const themeId = document.getElementById('theme').value; + const memberId = document.getElementById('member').value; + const dateFrom = document.getElementById('date-from').value; + const dateTo = document.getElementById('date-to').value; + + const queryParams = { + themeId: themeId, + memberId: memberId, + dateFrom: dateFrom, + dateTo: dateTo + } + const searchParams = new URLSearchParams(queryParams); + const endpoint = '/reservations/search'; + + const url = `${endpoint}?${searchParams.toString()}`; + fetch(url, { // 예약 검색 API 호출 + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }).then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }).then(render) + .catch(error => console.error("Error fetching available times:", error)); +} + +function requestCreate(reservation) { + const requestOptions = { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(reservation) + }; + + return fetch('/reservations/admin', requestOptions) + .then(response => { + if (response.status === 201) return response.json(); + throw new Error('Create failed'); + }); +} + +function requestDelete(id) { + const requestOptions = { + method: 'DELETE', + }; + + return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions) + .then(response => { + if (response.status !== 204) throw new Error('Delete failed'); + }); +} + +function requestRead(endpoint) { + return fetch(endpoint) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} diff --git a/src/main/resources/static/js/reservation.js b/src/main/resources/static/js/reservation.js new file mode 100644 index 00000000..a64d3dc5 --- /dev/null +++ b/src/main/resources/static/js/reservation.js @@ -0,0 +1,179 @@ +let isEditing = false; +const RESERVATION_API_ENDPOINT = '/reservations'; +const TIME_API_ENDPOINT = '/times'; +const timesOptions = []; + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('add-button').addEventListener('click', addInputRow); + + requestRead(RESERVATION_API_ENDPOINT) + .then(render) + .catch(error => console.error('Error fetching reservations:', error)); + + fetchTimes(); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + + data.data.reservations.forEach(item => { + const row = tableBody.insertRow(); + + row.insertCell(0).textContent = item.id; + row.insertCell(1).textContent = item.name; + row.insertCell(2).textContent = item.date; + row.insertCell(3).textContent = item.time.startAt; + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); + }); +} + +function fetchTimes() { + requestRead(TIME_API_ENDPOINT) + .then(data => { + timesOptions.push(...data.data.times); + }) + .catch(error => console.error('Error fetching time:', error)); +} + +function createSelect(options, defaultText, selectId, textProperty) { + const select = document.createElement('select'); + select.className = 'form-control'; + select.id = selectId; + + // 기본 옵션 추가 + const defaultOption = document.createElement('option'); + defaultOption.textContent = defaultText; + select.appendChild(defaultOption); + + // 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성 + options.forEach(optionData => { + const option = document.createElement('option'); + option.value = optionData.id; + option.textContent = optionData[textProperty]; // 동적 속성 접근 + select.appendChild(option); + }); + + return select; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function addInputRow() { + if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 + + const tableBody = document.getElementById('table-body'); + const row = tableBody.insertRow(); + isEditing = true; + + const nameInput = createInput('text'); + const dateInput = createInput('date'); + const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt'); + + const cellFieldsToCreate = ['', nameInput, dateInput, timeDropdown]; + + cellFieldsToCreate.forEach((field, index) => { + const cell = row.insertCell(index); + if (typeof field === 'string') { + cell.textContent = field; + } else { + cell.appendChild(field); + } + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow)); + actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { + row.remove(); + isEditing = false; + })); +} + +function createInput(type) { + const input = document.createElement('input'); + input.type = type; + input.className = 'form-control'; + return input; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function saveRow(event) { + // 이벤트 전파를 막는다 + event.stopPropagation(); + + const row = event.target.parentNode.parentNode; + const nameInput = row.querySelector('input[type="text"]'); + const dateInput = row.querySelector('input[type="date"]'); + const timeSelect = row.querySelector('select'); + + const reservation = { + name: nameInput.value, + date: dateInput.value, + timeId: timeSelect.value + }; + + requestCreate(reservation) + .then(() => { + location.reload(); + }) + .catch(error => console.error('Error:', error)); + + isEditing = false; // isEditing 값을 false로 설정 +} + +function deleteRow(event) { + const row = event.target.closest('tr'); + const reservationId = row.cells[0].textContent; + + requestDelete(reservationId) + .then(() => row.remove()) + .catch(error => console.error('Error:', error)); +} + +function requestCreate(reservation) { + const requestOptions = { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(reservation) + }; + + return fetch(RESERVATION_API_ENDPOINT, requestOptions) + .then(response => { + if (response.status === 201) return response.json(); + throw new Error('Create failed'); + }); +} + +function requestDelete(id) { + const requestOptions = { + method: 'DELETE', + }; + + return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions) + .then(response => { + if (response.status !== 204) throw new Error('Delete failed'); + }); +} + +function requestRead(endpoint) { + return fetch(endpoint) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} diff --git a/src/main/resources/static/js/scripts.js b/src/main/resources/static/js/scripts.js new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/static/js/theme.js b/src/main/resources/static/js/theme.js new file mode 100644 index 00000000..fda35892 --- /dev/null +++ b/src/main/resources/static/js/theme.js @@ -0,0 +1,136 @@ +let isEditing = false; +const API_ENDPOINT = '/themes'; +const cellFields = ['id', 'name', 'description', 'thumbnail']; +const createCellFields = ['', createInput(), createInput(), createInput()]; + +function createBody(inputs) { + return { + name: inputs[0].value, + description: inputs[1].value, + thumbnail: inputs[2].value, + }; +} + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('add-button').addEventListener('click', addRow); + requestRead() + .then(render) + .catch(error => console.error('Error fetching times:', error)); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + + data.data.themes.forEach(item => { + const row = tableBody.insertRow(); + + cellFields.forEach((field, index) => { + row.insertCell(index).textContent = item[field]; + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); + }); +} + +function addRow() { + if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 + + const tableBody = document.getElementById('table-body'); + const row = tableBody.insertRow(); + isEditing = true; + + createAddField(row); +} + +function createAddField(row) { + createCellFields.forEach((field, index) => { + const cell = row.insertCell(index); + if (typeof field === 'string') { + cell.textContent = field; + } else { + cell.appendChild(field); + } + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow)); + actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { + row.remove(); + isEditing = false; + })); +} + +function createInput() { + const input = document.createElement('input'); + input.className = 'form-control'; + return input; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function saveRow(event) { + const row = event.target.parentNode.parentNode; + const inputs = row.querySelectorAll('input'); + const body = createBody(inputs); + + requestCreate(body) + .then(() => { + location.reload(); + }) + .catch(error => console.error('Error:', error)); + + isEditing = false; // isEditing 값을 false로 설정 +} + +function deleteRow(event) { + const row = event.target.closest('tr'); + const id = row.cells[0].textContent; + + requestDelete(id) + .then(() => row.remove()) + .catch(error => console.error('Error:', error)); +} + + +// request + +function requestCreate(data) { + const requestOptions = { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }; + + return fetch(API_ENDPOINT, requestOptions) + .then(response => { + if (response.status === 201) return response.json(); + throw new Error('Create failed'); + }); +} + +function requestRead() { + return fetch(API_ENDPOINT) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} + +function requestDelete(id) { + const requestOptions = { + method: 'DELETE', + }; + + return fetch(`${API_ENDPOINT}/${id}`, requestOptions) + .then(response => { + if (response.status !== 204) throw new Error('Delete failed'); + }); +} diff --git a/src/main/resources/static/js/time.js b/src/main/resources/static/js/time.js new file mode 100644 index 00000000..98094df4 --- /dev/null +++ b/src/main/resources/static/js/time.js @@ -0,0 +1,135 @@ +let isEditing = false; +const API_ENDPOINT = '/times'; +const cellFields = ['id', 'startAt']; +const createCellFields = ['', createInput()]; + +function createBody(inputs) { + return { + startAt: inputs[0].value, + }; +} + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('add-button').addEventListener('click', addRow); + requestRead() + .then(render) + .catch(error => console.error('Error fetching times:', error)); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + + data.data.times.forEach(item => { + const row = tableBody.insertRow(); + + cellFields.forEach((field, index) => { + row.insertCell(index).textContent = item[field]; + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); + }); +} + +function addRow() { + if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 + + const tableBody = document.getElementById('table-body'); + const row = tableBody.insertRow(); + isEditing = true; + + createAddField(row); +} + +function createAddField(row) { + createCellFields.forEach((field, index) => { + const cell = row.insertCell(index); + if (typeof field === 'string') { + cell.textContent = field; + } else { + cell.appendChild(field); + } + }); + + const actionCell = row.insertCell(row.cells.length); + actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow)); + actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { + row.remove(); + isEditing = false; + })); +} + +function createInput() { + const input = document.createElement('input'); + input.type = 'time' + input.className = 'form-control'; + return input; +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} + +function saveRow(event) { + const row = event.target.parentNode.parentNode; + const inputs = row.querySelectorAll('input'); + const body = createBody(inputs); + + requestCreate(body) + .then(() => { + location.reload(); + }) + .catch(error => console.error('Error:', error)); + + isEditing = false; // isEditing 값을 false로 설정 +} + +function deleteRow(event) { + const row = event.target.closest('tr'); + const id = row.cells[0].textContent; + + requestDelete(id) + .then(() => row.remove()) + .catch(error => console.error('Error:', error)); +} + + +// request + +function requestCreate(data) { + const requestOptions = { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }; + + return fetch(API_ENDPOINT, requestOptions) + .then(response => { + if (response.status === 201) return response.json(); + throw new Error('Create failed'); + }); +} + +function requestRead() { + return fetch(API_ENDPOINT) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} + +function requestDelete(id) { + const requestOptions = { + method: 'DELETE', + }; + + return fetch(`${API_ENDPOINT}/${id}`, requestOptions) + .then(response => { + if (response.status !== 204) throw new Error('Delete failed'); + }); +} diff --git a/src/main/resources/static/js/user-reservation.js b/src/main/resources/static/js/user-reservation.js new file mode 100644 index 00000000..1bfd937b --- /dev/null +++ b/src/main/resources/static/js/user-reservation.js @@ -0,0 +1,273 @@ +const THEME_API_ENDPOINT = '/themes'; + +document.addEventListener('DOMContentLoaded', () => { + requestRead(THEME_API_ENDPOINT) + .then(renderTheme) + .catch(error => console.error('Error fetching times:', error)); + + flatpickr("#datepicker", { + inline: true, + onChange: function (selectedDates, dateStr, instance) { + if (dateStr === '') return; + checkDate(); + } + }); + + // ------ 결제위젯 초기화 ------ + // @docs https://docs.tosspayments.com/reference/widget-sdk#sdk-설치-및-초기화 + // @docs https://docs.tosspayments.com/reference/widget-sdk#renderpaymentmethods선택자-결제-금액-옵션 + const paymentAmount = 1000; + const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm"; + const paymentWidget = PaymentWidget(widgetClientKey, PaymentWidget.ANONYMOUS); + paymentWidget.renderPaymentMethods( + "#payment-method", + {value: paymentAmount}, + {variantKey: "DEFAULT"} + ); + + document.getElementById('theme-slots').addEventListener('click', event => { + if (event.target.classList.contains('theme-slot')) { + document.querySelectorAll('.theme-slot').forEach(slot => slot.classList.remove('active')); + event.target.classList.add('active'); + checkDateAndTheme(); + } + }); + + document.getElementById('time-slots').addEventListener('click', event => { + if (event.target.classList.contains('time-slot') && !event.target.classList.contains('disabled')) { + document.querySelectorAll('.time-slot').forEach(slot => slot.classList.remove('active')); + event.target.classList.add('active'); + checkDateAndThemeAndTime(); + } + }); + + document.getElementById('reserve-button').addEventListener('click', onReservationButtonClickWithPaymentWidget); + document.getElementById('wait-button').addEventListener('click', onWaitButtonClick); + + function onReservationButtonClickWithPaymentWidget(event) { + onReservationButtonClick(event, paymentWidget); + } +}); + +function renderTheme(themes) { + const themeSlots = document.getElementById('theme-slots'); + themeSlots.innerHTML = ''; + themes.data.themes.forEach(theme => { + const name = theme.name; + const themeId = theme.id; + themeSlots.appendChild(createSlot('theme', name, themeId)); + }); +} + +function createSlot(type, text, id, booked) { + const div = document.createElement('div'); + div.className = type + '-slot cursor-pointer bg-light border rounded p-3 mb-2'; + div.textContent = text; + div.setAttribute('data-' + type + '-id', id); + if (type === 'time') { + div.setAttribute('data-time-booked', booked); + } + return div; +} + +function checkDate() { + const selectedDate = document.getElementById("datepicker").value; + if (selectedDate) { + const themeSection = document.getElementById("theme-section"); + if (themeSection.classList.contains("disabled")) { + themeSection.classList.remove("disabled"); + } + const timeSlots = document.getElementById('time-slots'); + timeSlots.innerHTML = ''; + + requestRead(THEME_API_ENDPOINT) + .then(renderTheme) + .catch(error => console.error('Error fetching times:', error)); + } +} + +function checkDateAndTheme() { + const selectedDate = document.getElementById("datepicker").value; + const selectedThemeElement = document.querySelector('.theme-slot.active'); + if (selectedDate && selectedThemeElement) { + const selectedThemeId = selectedThemeElement.getAttribute('data-theme-id'); + fetchAvailableTimes(selectedDate, selectedThemeId); + } +} + +function fetchAvailableTimes(date, themeId) { + + fetch(`/times/filter?date=${date}&themeId=${themeId}`, { // 예약 가능 시간 조회 API endpoint + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }).then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }).then(renderAvailableTimes) + .catch(error => console.error("Error fetching available times:", error)); +} + +function renderAvailableTimes(times) { + const timeSection = document.getElementById("time-section"); + if (timeSection.classList.contains("disabled")) { + timeSection.classList.remove("disabled"); + } + + const timeSlots = document.getElementById('time-slots'); + timeSlots.innerHTML = ''; + if (times.length === 0) { + timeSlots.innerHTML = '
선택할 수 있는 시간이 없습니다.
'; + return; + } + times.data.reservationTimes.forEach(time => { + const startAt = time.startAt; + const timeId = time.timeId; + const alreadyBooked = time.alreadyBooked; + + const div = createSlot('time', startAt, timeId, alreadyBooked); // createSlot('time', 시작 시간, time id, 예약 여부) + timeSlots.appendChild(div); + }); +} + +function checkDateAndThemeAndTime() { + const selectedDate = document.getElementById("datepicker").value; + const selectedThemeElement = document.querySelector('.theme-slot.active'); + const selectedTimeElement = document.querySelector('.time-slot.active'); + const reserveButton = document.getElementById("reserve-button"); + const waitButton = document.getElementById("wait-button"); + + if (selectedDate && selectedThemeElement && selectedTimeElement) { + if (selectedTimeElement.getAttribute('data-time-booked') === 'true') { + // 선택된 시간이 이미 예약된 경우 + reserveButton.classList.add("disabled"); + waitButton.classList.remove("disabled"); // 예약 대기 버튼 활성화 + } else { + // 선택된 시간이 예약 가능한 경우 + reserveButton.classList.remove("disabled"); + waitButton.classList.add("disabled"); // 예약 대기 버튼 활성화 + } + } else { + // 날짜, 테마, 시간 중 하나라도 선택되지 않은 경우 + reserveButton.classList.add("disabled"); + waitButton.classList.add("disabled"); + } +} + +function onReservationButtonClick(event, paymentWidget) { + const selectedDate = document.getElementById("datepicker").value; + const selectedThemeId = document.querySelector('.theme-slot.active')?.getAttribute('data-theme-id'); + const selectedTimeId = document.querySelector('.time-slot.active')?.getAttribute('data-time-id'); + + if (selectedDate && selectedThemeId && selectedTimeId) { + const reservationData = { + date: selectedDate, + themeId: selectedThemeId, + timeId: selectedTimeId, + }; + + const generateRandomString = () => + window.btoa(Math.random()).slice(0, 20); + + // TOSS 결제 위젯 Javascript SDK 연동 방식 중 'Promise로 처리하기'를 적용함 + // https://docs.tosspayments.com/reference/widget-sdk#promise%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0 + const orderIdPrefix = "WTEST"; + paymentWidget.requestPayment({ + orderId: orderIdPrefix + generateRandomString(), + orderName: "테스트 방탈출 예약 결제 1건", + amount: 1000, + }).then(function (data) { + console.debug(data); + fetchReservationPayment(data, reservationData); + }).catch(function (error) { + // TOSS 에러 처리: 에러 목록을 확인하세요 + // https://docs.tosspayments.com/reference/error-codes#failurl 로-전달되는-에러 + alert(error.code + " :" + error.message); + }); + + } else { + alert("Please select a date, theme, and time before making a reservation."); + } +} + +async function fetchReservationPayment(paymentData, reservationData) { + const reservationPaymentRequest = { + date: reservationData.date, + themeId: reservationData.themeId, + timeId: reservationData.timeId, + paymentKey: paymentData.paymentKey, + orderId: paymentData.orderId, + amount: paymentData.amount, + paymentType: paymentData.paymentType, + } + + const reservationURL = "/reservations"; + fetch(reservationURL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(reservationPaymentRequest), + }).then(response => { + if (!response.ok) { + return response.json().then(errorBody => { + console.error("예약 결제 실패 : " + JSON.stringify(errorBody)); + window.alert("예약 결제 실패 메시지: " + errorBody.message); + }); + } else { + response.json().then(successBody => { + alert("예약이 완료되었습니다."); + console.log("예약 결제 성공 : " + JSON.stringify(successBody)); + window.location.href = "/"; + }); + } + }).catch(error => { + console.error(error.message); + }); +} + +function onWaitButtonClick() { + const selectedDate = document.getElementById("datepicker").value; + const selectedThemeId = document.querySelector('.theme-slot.active')?.getAttribute('data-theme-id'); + const selectedTimeId = document.querySelector('.time-slot.active')?.getAttribute('data-time-id'); + + if (selectedDate && selectedThemeId && selectedTimeId) { + const reservationData = { + date: selectedDate, + timeId: selectedTimeId, + themeId: selectedThemeId, + }; + + fetch('/reservations/waiting', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(reservationData) + }) + .then(response => { + if (!response.ok) throw new Error('Reservation waiting failed'); + return response.json(); + }) + .then(data => { + alert('Reservation waiting successful!'); + window.location.href = "/"; + }) + .catch(error => { + alert("An error occurred while making the reservation waiting."); + console.error(error); + }); + } else { + alert("Please select a date, theme, and time before making a reservation waiting."); + } +} + + +function requestRead(endpoint) { + return fetch(endpoint) + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }); +} diff --git a/src/main/resources/static/js/user-scripts.js b/src/main/resources/static/js/user-scripts.js new file mode 100644 index 00000000..2a1e253a --- /dev/null +++ b/src/main/resources/static/js/user-scripts.js @@ -0,0 +1,152 @@ +document.addEventListener('DOMContentLoaded', function () { + updateUIBasedOnLogin(); +}); + +document.getElementById('logout-btn').addEventListener('click', function (event) { + event.preventDefault(); + fetch('/logout', { + method: 'POST', // 또는 서버 설정에 따라 GET 일 수도 있음 + credentials: 'include' // 쿠키를 포함시키기 위해 필요 + }) + .then(response => { + if (response.ok) { + // 로그아웃 성공, 페이지 새로고침 또는 리다이렉트 + window.location.reload(); + } else { + // 로그아웃 실패 처리 + console.error('Logout failed'); + } + }) + .catch(error => { + console.error('Error:', error); + }); +}); + +function updateUIBasedOnLogin() { + fetch('/login/check') // 로그인 상태 확인 API 호출 + .then(response => { + if (!response.ok) { // 요청이 실패하거나 로그인 상태가 아닌 경우 + throw new Error('Not logged in or other error'); + } + return response.json(); // 응답 본문을 JSON으로 파싱 + }) + .then(data => { + // 응답에서 사용자 이름을 추출하여 UI 업데이트 + document.getElementById('profile-name').textContent = data.data.name; // 프로필 이름 설정 + document.querySelector('.nav-item.dropdown').style.display = 'block'; // 드롭다운 메뉴 표시 + document.querySelector('.nav-item a[href="/login"]').parentElement.style.display = 'none'; // 로그인 버튼 숨김 + }) + .catch(error => { + // 에러 처리 또는 로그아웃 상태일 때 UI 업데이트 + console.error('Error:', error); + document.getElementById('profile-name').textContent = 'Profile'; // 기본 텍스트로 재설정 + document.querySelector('.nav-item.dropdown').style.display = 'none'; // 드롭다운 메뉴 숨김 + document.querySelector('.nav-item a[href="/login"]').parentElement.style.display = 'block'; // 로그인 버튼 표시 + }); +} + +// 드롭다운 메뉴 토글 +document.getElementById("navbarDropdown").addEventListener('click', function (e) { + e.preventDefault(); + const dropdownMenu = e.target.closest('.nav-item.dropdown').querySelector('.dropdown-menu'); + dropdownMenu.classList.toggle('show'); // Bootstrap 4에서는 data-toggle 사용, Bootstrap 5에서는 JS로 처리 +}); + + +function login() { + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + // 입력 필드 검증 + if (!email || !password) { + alert('Please fill in all fields.'); + return; // 필수 입력 필드가 비어있으면 여기서 함수 실행을 중단 + } + + fetch('/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: email, + password: password + }) + }) + .then(response => { + if (200 !== response.status) { + alert('Login failed'); // 로그인 실패 시 경고창 표시 + throw new Error('Login failed'); + } + }) + .then(() => { + updateUIBasedOnLogin(); // UI 업데이트 + window.location.href = '/'; + }) + .catch(error => { + console.error('Error during login:', error); + }); +} + +function signup() { + // Redirect to signup page + window.location.href = '/signup'; +} + +function register(event) { + // 폼 데이터 수집 + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + const name = document.getElementById('name').value; + + // 입력 필드 검증 + if (!email || !password || !name) { + alert('Please fill in all fields.'); + return; // 필수 입력 필드가 비어있으면 여기서 함수 실행을 중단 + } + + // 요청 데이터 포맷팅 + const formData = { + email: email, + password: password, + name: name + }; + + // AJAX 요청 생성 및 전송 + fetch('/members', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }) + .then(response => { + if (!response.ok) { + alert('Signup request failed'); + throw new Error('Signup request failed'); + } + return response.json(); // 여기서 응답을 JSON 형태로 변환 + }) + .then(data => { + // 성공적인 응답 처리 + console.log('Signup successful:', data); + window.location.href = '/login'; + }) + .catch(error => { + // 에러 처리 + console.error('Error during signup:', error); + }); + + // 폼 제출에 의한 페이지 리로드 방지 + event.preventDefault(); +} + +function base64DecodeUnicode(str) { + // Base64 디코딩 + const decodedBytes = atob(str); + // UTF-8 바이트를 문자열로 변환 + const encodedUriComponent = decodedBytes.split('').map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join(''); + return decodeURIComponent(encodedUriComponent); +} diff --git a/src/main/resources/static/js/waiting.js b/src/main/resources/static/js/waiting.js new file mode 100644 index 00000000..cea6f6f1 --- /dev/null +++ b/src/main/resources/static/js/waiting.js @@ -0,0 +1,69 @@ +document.addEventListener('DOMContentLoaded', () => { + fetch('/reservations/waiting') // 내 예약 목록 조회 API 호출 + .then(response => { + if (response.status === 200) return response.json(); + throw new Error('Read failed'); + }) + .then(render) + .catch(error => console.error('Error fetching reservations:', error)); +}); + +function render(data) { + const tableBody = document.getElementById('table-body'); + tableBody.innerHTML = ''; + + data.data.reservations.forEach(item => { + const row = tableBody.insertRow(); + + const id = item.id; + const name = item.member.name; + const theme = item.theme.name; + const date = item.date; + const startAt = item.time.startAt; + + row.insertCell(0).textContent = id; // 예약 대기 id + row.insertCell(1).textContent = name; // 예약자명 + row.insertCell(2).textContent = theme; // 테마명 + row.insertCell(3).textContent = date; // 예약 날짜 + row.insertCell(4).textContent = startAt; // 시작 시간 + + const actionCell = row.insertCell(row.cells.length); + + actionCell.appendChild(createActionButton('승인', 'btn-primary', approve)); + actionCell.appendChild(createActionButton('거절', 'btn-danger', deny)); + }); +} + +function approve(event) { + const row = event.target.closest('tr'); + const id = row.cells[0].textContent; + + const endpoint = `/reservations/waiting/${id}/approve` + return fetch(endpoint, { + method: 'POST' + }).then(response => { + if (response.status === 200) return; + throw new Error('Delete failed'); + }).then(() => location.reload()); +} + +function deny(event) { + const row = event.target.closest('tr'); + const id = row.cells[0].textContent; + + const endpoint = `/reservations/waiting/${id}/deny` + return fetch(endpoint, { + method: 'POST' + }).then(response => { + if (response.status === 204) return; + throw new Error('Delete failed'); + }).then(() => location.reload()); +} + +function createActionButton(label, className, eventListener) { + const button = document.createElement('button'); + button.textContent = label; + button.classList.add('btn', className, 'mr-2'); + button.addEventListener('click', eventListener); + return button; +} diff --git a/src/main/resources/templates/admin/index.html b/src/main/resources/templates/admin/index.html new file mode 100644 index 00000000..3a4a7254 --- /dev/null +++ b/src/main/resources/templates/admin/index.html @@ -0,0 +1,61 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

방탈출 어드민

+
+ + + + diff --git a/src/main/resources/templates/admin/reservation-new.html b/src/main/resources/templates/admin/reservation-new.html new file mode 100644 index 00000000..527f0475 --- /dev/null +++ b/src/main/resources/templates/admin/reservation-new.html @@ -0,0 +1,111 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

방탈출 예약 페이지

+
+
+
+ +
+ + + + + + + + + + + + + + +
예약번호예약자테마날짜시간결제 완료 여부
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + + + diff --git a/src/main/resources/templates/admin/reservation.html b/src/main/resources/templates/admin/reservation.html new file mode 100644 index 00000000..a827db6b --- /dev/null +++ b/src/main/resources/templates/admin/reservation.html @@ -0,0 +1,63 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

방탈출 예약 페이지

+
+ +
+
+ + + + + + + + + + + + +
예약번호예약자날짜시간
+
+ + + + diff --git a/src/main/resources/templates/admin/theme.html b/src/main/resources/templates/admin/theme.html new file mode 100644 index 00000000..70b39718 --- /dev/null +++ b/src/main/resources/templates/admin/theme.html @@ -0,0 +1,80 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

테마 관리 페이지

+
+ +
+
+ + + + + + + + + + + + +
순서제목설명썸네일 URL
+
+ + + + + + diff --git a/src/main/resources/templates/admin/time.html b/src/main/resources/templates/admin/time.html new file mode 100644 index 00000000..f0152542 --- /dev/null +++ b/src/main/resources/templates/admin/time.html @@ -0,0 +1,78 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

시간 관리 페이지

+
+ +
+
+ + + + + + + + + + +
순서시간
+
+ + + + + + diff --git a/src/main/resources/templates/admin/waiting.html b/src/main/resources/templates/admin/waiting.html new file mode 100644 index 00000000..32c30370 --- /dev/null +++ b/src/main/resources/templates/admin/waiting.html @@ -0,0 +1,77 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

예약 대기 관리 페이지

+
+ + + + + + + + + + + + + +
예약대기 번호예약자테마날짜시간
+
+ + + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 00000000..9740e2ef --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,56 @@ + + + + + + 방탈출 예약 페이지 + + + + + + + + +
+

인기 테마

+
    +
+
+ + + + + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 00000000..8faab43f --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,64 @@ + + + + + + Login + + + + + + + + +
+

Login

+
+
+ +
+
+ +
+
+ + +
+
+
+ + + + diff --git a/src/main/resources/templates/reservation-mine.html b/src/main/resources/templates/reservation-mine.html new file mode 100644 index 00000000..f2310ca0 --- /dev/null +++ b/src/main/resources/templates/reservation-mine.html @@ -0,0 +1,70 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

내 예약

+
+ + + + + + + + + + + + + + + +
테마날짜시간상태대기 취소paymentKey결제금액
+
+ + + + + diff --git a/src/main/resources/templates/reservation.html b/src/main/resources/templates/reservation.html new file mode 100644 index 00000000..29c9c8ba --- /dev/null +++ b/src/main/resources/templates/reservation.html @@ -0,0 +1,103 @@ + + + + + + 방탈출 예약 페이지 + + + + + + + + + + + + + +
+

예약 페이지

+
+ +
+

날짜 선택

+
+
+
+
+ + +
+

테마 선택

+
+ +
+
+ + +
+

시간 선택

+
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
+ + + + + + + + diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html new file mode 100644 index 00000000..6f044d2f --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,67 @@ + + + + + + Signup + + + + + + + + +
+

Signup

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/src/test/java/roomescape/global/auth/jwt/JwtHandlerTest.java b/src/test/java/roomescape/global/auth/jwt/JwtHandlerTest.java new file mode 100644 index 00000000..8b22eb86 --- /dev/null +++ b/src/test/java/roomescape/global/auth/jwt/JwtHandlerTest.java @@ -0,0 +1,118 @@ +package roomescape.global.auth.jwt; + +import static roomescape.system.exception.ErrorType.*; + +import java.util.Date; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.restassured.RestAssured; +import roomescape.system.auth.jwt.JwtHandler; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class JwtHandlerTest { + + @Autowired + private JwtHandler jwtHandler; + + @Value("${security.jwt.token.secret-key}") + private String secretKey; + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + @DisplayName("토큰이 만료되면 401 Unauthorized 를 발생시킨다.") + void jwtExpired() { + //given + Date date = new Date(); + Date expiredAt = new Date(date.getTime() - 1); + + String accessToken = Jwts.builder() + .claim("memberId", 1L) + .setIssuedAt(date) + .setExpiration(expiredAt) + .signWith(SignatureAlgorithm.HS256, secretKey.getBytes()) + .compact(); + + // when & then + Assertions.assertThatThrownBy(() -> jwtHandler.getMemberIdFromToken(accessToken)) + .isInstanceOf(RoomEscapeException.class) + .hasMessage(EXPIRED_TOKEN.getDescription()); + } + + @Test + @DisplayName("지원하지 않는 토큰이면 401 Unauthorized 를 발생시킨다.") + void jwtMalformed() { + // given + Date date = new Date(); + Date expiredAt = new Date(date.getTime() + 100000); + + String accessToken = Jwts.builder() + .claim("memberId", 1L) + .setIssuedAt(date) + .setExpiration(expiredAt) + .signWith(SignatureAlgorithm.HS256, secretKey.getBytes()) + .compact(); + + String[] splitAccessToken = accessToken.split("\\."); + String unsupportedAccessToken = splitAccessToken[0] + "." + splitAccessToken[1]; + + // when & then + Assertions.assertThatThrownBy(() -> jwtHandler.getMemberIdFromToken(unsupportedAccessToken)) + .isInstanceOf(RoomEscapeException.class) + .hasMessage(ErrorType.MALFORMED_TOKEN.getDescription()); + } + + @Test + @DisplayName("토큰의 Signature 가 잘못되었다면 401 Unauthorized 를 발생시킨다.") + void jwtInvalidSignature() { + // given + Date date = new Date(); + Date expiredAt = new Date(date.getTime() + 100000); + + String invalidSecretKey = secretKey.substring(1); + + String accessToken = Jwts.builder() + .claim("memberId", 1L) + .setIssuedAt(date) + .setExpiration(expiredAt) + .signWith(SignatureAlgorithm.HS256, invalidSecretKey.getBytes()) // 기존은 HS256 알고리즘 + .compact(); + + // when & then + Assertions.assertThatThrownBy(() -> jwtHandler.getMemberIdFromToken(accessToken)) + .isInstanceOf(RoomEscapeException.class) + .hasMessage(ErrorType.INVALID_SIGNATURE_TOKEN.getDescription()); + } + + @Test + @DisplayName("토큰이 공백값이라면 401 Unauthorized 를 발생시킨다.") + void jwtIllegal() { + // given + String accessToken = ""; + + // when & then + Assertions.assertThatThrownBy(() -> jwtHandler.getMemberIdFromToken(accessToken)) + .isInstanceOf(RoomEscapeException.class) + .hasMessage(ErrorType.ILLEGAL_TOKEN.getDescription()); + } +} diff --git a/src/test/java/roomescape/member/controller/MemberControllerTest.java b/src/test/java/roomescape/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..5cf989d9 --- /dev/null +++ b/src/test/java/roomescape/member/controller/MemberControllerTest.java @@ -0,0 +1,72 @@ +package roomescape.member.controller; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class MemberControllerTest { + + @Autowired + private MemberRepository memberRepository; + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + @DisplayName("/members 로 GET 요청을 보내면 회원 정보와 200 OK 를 받는다.") + void getAdminPage() { + // given + String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + memberRepository.save(new Member("이름1", "test@test.com", "password", Role.MEMBER)); + memberRepository.save(new Member("이름2", "test@test.com", "password", Role.MEMBER)); + memberRepository.save(new Member("이름3", "test@test.com", "password", Role.MEMBER)); + memberRepository.save(new Member("이름4", "test@test.com", "password", Role.MEMBER)); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/members") + .then().log().all() + .statusCode(200); + } + + private String getAdminAccessTokenCookieByLogin(final String email, final String password) { + memberRepository.save(new Member("이름", email, password, Role.ADMIN)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } +} diff --git a/src/test/java/roomescape/member/domain/MemberTest.java b/src/test/java/roomescape/member/domain/MemberTest.java new file mode 100644 index 00000000..666982fd --- /dev/null +++ b/src/test/java/roomescape/member/domain/MemberTest.java @@ -0,0 +1,26 @@ +package roomescape.member.domain; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import roomescape.system.exception.RoomEscapeException; + +class MemberTest { + + @Test + @DisplayName("Member 객체를 생성할 때 Role은 반드시 입력되어야 한다.") + void createMemberWithoutRole() { + // given + String name = "name"; + String email = "email"; + String password = "password"; + + // when + Role role = null; + + // then + Assertions.assertThatThrownBy(() -> new Member(name, email, password, null)) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/payment/client/SampleTossPaymentConst.java b/src/test/java/roomescape/payment/client/SampleTossPaymentConst.java new file mode 100644 index 00000000..688cc27f --- /dev/null +++ b/src/test/java/roomescape/payment/client/SampleTossPaymentConst.java @@ -0,0 +1,178 @@ +package roomescape.payment.client; + +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.request.PaymentRequest; + +public class SampleTossPaymentConst { + + public static final PaymentRequest paymentRequest = new PaymentRequest( + "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", "MC4wODU4ODQwMzg4NDk0", 1000L, "카드"); + + public static final String paymentRequestJson = """ + { + "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", + "orderId": "MC4wODU4ODQwMzg4NDk0", + "amount": 1000, + "paymentType": "카드" + } + """; + + public static final PaymentCancelRequest cancelRequest = new PaymentCancelRequest( + "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", 1000L, "테스트 결제 취소"); + + public static final String cancelRequestJson = """ + { + "cancelReason": "테스트 결제 취소" + } + """; + + public static final String tossPaymentErrorJson = """ + { + "code": "ERROR_CODE", + "message": "Error message" + } + """; + + public static final String confirmJson = """ + { + "mId": "tosspayments", + "lastTransactionKey": "9C62B18EEF0DE3EB7F4422EB6D14BC6E", + "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", + "orderId": "MC4wODU4ODQwMzg4NDk0", + "orderName": "토스 티셔츠 외 2건", + "taxExemptionAmount": 0, + "status": "DONE", + "requestedAt": "2024-02-13T12:17:57+09:00", + "approvedAt": "2024-02-13T12:18:14+09:00", + "useEscrow": false, + "cultureExpense": false, + "card": { + "issuerCode": "71", + "acquirerCode": "71", + "number": "12345678****000*", + "installmentPlanMonths": 0, + "isInterestFree": false, + "interestPayer": null, + "approveNo": "00000000", + "useCardPoint": false, + "cardType": "신용", + "ownerType": "개인", + "acquireStatus": "READY", + "receiptUrl": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX", + "amount": 1000 + }, + "virtualAccount": null, + "transfer": null, + "mobilePhone": null, + "giftCertificate": null, + "cashReceipt": null, + "cashReceipts": null, + "discount": null, + "cancels": null, + "secret": null, + "type": "NORMAL", + "easyPay": { + "provider": "토스페이", + "amount": 0, + "discountAmount": 0 + }, + "easyPayAmount": 0, + "easyPayDiscountAmount": 0, + "country": "KR", + "failure": null, + "isPartialCancelable": true, + "receipt": { + "url": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX" + }, + "checkout": { + "url": "https://api.tosspayments.com/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/checkout" + }, + "currency": "KRW", + "totalAmount": 1000, + "balanceAmount": 1000, + "suppliedAmount": 909, + "vat": 91, + "taxFreeAmount": 0, + "method": "카드", + "version": "2022-11-16" + } + """; + + public static final String cancelJson = """ + { + "mId": "tosspayments", + "lastTransactionKey": "090A796806E726BBB929F4A2CA7DB9A7", + "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", + "orderId": "MC4wODU4ODQwMzg4NDk0", + "orderName": "토스 티셔츠 외 2건", + "taxExemptionAmount": 0, + "status": "CANCELED", + "requestedAt": "2024-02-13T12:17:57+09:00", + "approvedAt": "2024-02-13T12:18:14+09:00", + "useEscrow": false, + "cultureExpense": false, + "card": { + "issuerCode": "71", + "acquirerCode": "71", + "number": "12345678****000*", + "installmentPlanMonths": 0, + "isInterestFree": false, + "interestPayer": null, + "approveNo": "00000000", + "useCardPoint": false, + "cardType": "신용", + "ownerType": "개인", + "acquireStatus": "READY", + "amount": 1000 + }, + "virtualAccount": null, + "transfer": null, + "mobilePhone": null, + "giftCertificate": null, + "cashReceipt": null, + "cashReceipts": null, + "discount": null, + "cancels": [ + { + "transactionKey": "090A796806E726BBB929F4A2CA7DB9A7", + "cancelReason": "테스트 결제 취소", + "taxExemptionAmount": 0, + "canceledAt": "2024-02-13T12:20:23+09:00", + "easyPayDiscountAmount": 0, + "receiptKey": null, + "cancelAmount": 1000, + "taxFreeAmount": 0, + "refundableAmount": 0, + "cancelStatus": "DONE", + "cancelRequestId": null + } + ], + "secret": null, + "type": "NORMAL", + "easyPay": { + "provider": "토스페이", + "amount": 0, + "discountAmount": 0 + }, + "easyPayAmount": 0, + "easyPayDiscountAmount": 0, + "country": "KR", + "failure": null, + "isPartialCancelable": true, + "receipt": { + "url": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX" + }, + "checkout": { + "url": "https://api.tosspayments.com/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/checkout" + }, + "currency": "KRW", + "totalAmount": 1000, + "balanceAmount": 0, + "suppliedAmount": 0, + "vat": 0, + "taxFreeAmount": 0, + "method": "카드", + "version": "2022-11-16" + } + """; +} diff --git a/src/test/java/roomescape/payment/client/TossPaymentClientTest.java b/src/test/java/roomescape/payment/client/TossPaymentClientTest.java new file mode 100644 index 00000000..2e37c791 --- /dev/null +++ b/src/test/java/roomescape/payment/client/TossPaymentClientTest.java @@ -0,0 +1,120 @@ +package roomescape.payment.client; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; + +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.request.PaymentRequest; +import roomescape.payment.dto.response.PaymentCancelResponse; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.system.exception.ErrorType; +import roomescape.system.exception.RoomEscapeException; + +@RestClientTest(TossPaymentClient.class) +class TossPaymentClientTest { + + @Autowired + private TossPaymentClient tossPaymentClient; + + @Autowired + private MockRestServiceServer mockServer; + + @Test + @DisplayName("결제를 승인한다.") + void confirmPayment() { + // given + mockServer.expect(requestTo("/v1/payments/confirm")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(SampleTossPaymentConst.paymentRequestJson)) + .andRespond(withStatus(HttpStatus.OK) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.confirmJson)); + + // when + PaymentRequest paymentRequest = SampleTossPaymentConst.paymentRequest; + PaymentResponse paymentResponse = tossPaymentClient.confirmPayment(paymentRequest); + + // then + assertThat(paymentResponse.paymentKey()).isEqualTo(paymentRequest.paymentKey()); + assertThat(paymentResponse.orderId()).isEqualTo(paymentRequest.orderId()); + } + + @Test + @DisplayName("결제를 취소한다.") + void cancelPayment() { + // given + mockServer.expect(requestTo("/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/cancel")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(SampleTossPaymentConst.cancelRequestJson)) + .andRespond(withStatus(HttpStatus.OK) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.cancelJson)); + + // when + PaymentCancelRequest cancelRequest = SampleTossPaymentConst.cancelRequest; + PaymentCancelResponse paymentCancelResponse = tossPaymentClient.cancelPayment(cancelRequest); + + // then + assertThat(paymentCancelResponse.cancelStatus()).isEqualTo("DONE"); + assertThat(paymentCancelResponse.cancelReason()).isEqualTo(cancelRequest.cancelReason()); + } + + @Test + @DisplayName("결제 승인 중 400 에러가 발생한다.") + void confirmPaymentWithError() { + // given + mockServer.expect(requestTo("/v1/payments/confirm")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(SampleTossPaymentConst.paymentRequestJson)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.tossPaymentErrorJson)); + + // when & then + assertThatThrownBy(() -> tossPaymentClient.confirmPayment(SampleTossPaymentConst.paymentRequest)) + .isInstanceOf(RoomEscapeException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.PAYMENT_ERROR) + .hasFieldOrPropertyWithValue("invalidValue", + Optional.of("[ErrorCode = ERROR_CODE, ErrorMessage = Error message]")) + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("결제 취소 중 500 에러가 발생한다.") + void cancelPaymentWithError() { + // given + mockServer.expect(requestTo("/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/cancel")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(SampleTossPaymentConst.cancelRequestJson)) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.tossPaymentErrorJson)); + + // when & then + assertThatThrownBy(() -> tossPaymentClient.cancelPayment(SampleTossPaymentConst.cancelRequest)) + .isInstanceOf(RoomEscapeException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.PAYMENT_SERVER_ERROR) + .hasFieldOrPropertyWithValue("invalidValue", + Optional.of("[ErrorCode = ERROR_CODE, ErrorMessage = Error message]")) + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/test/java/roomescape/payment/domain/CanceledPaymentTest.java b/src/test/java/roomescape/payment/domain/CanceledPaymentTest.java new file mode 100644 index 00000000..5ec19814 --- /dev/null +++ b/src/test/java/roomescape/payment/domain/CanceledPaymentTest.java @@ -0,0 +1,22 @@ +package roomescape.payment.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import roomescape.system.exception.RoomEscapeException; + +class CanceledPaymentTest { + + @Test + @DisplayName("취소 날짜가 승인 날짜 이전이면 예외가 발생한다") + void invalidDate() { + OffsetDateTime approvedAt = OffsetDateTime.now(); + OffsetDateTime canceledAt = approvedAt.minusMinutes(1L); + assertThatThrownBy(() -> new CanceledPayment("payment-key", "reason", 10000L, approvedAt, canceledAt)) + .isInstanceOf(RoomEscapeException.class); + } +} \ No newline at end of file diff --git a/src/test/java/roomescape/payment/domain/PaymentTest.java b/src/test/java/roomescape/payment/domain/PaymentTest.java new file mode 100644 index 00000000..3c6e1fbc --- /dev/null +++ b/src/test/java/roomescape/payment/domain/PaymentTest.java @@ -0,0 +1,78 @@ +package roomescape.payment.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.NullSource; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; + +class PaymentTest { + + private Reservation reservation; + + @BeforeEach + void setUp() { + LocalDate now = LocalDate.now(); + ReservationTime reservationTime = new ReservationTime(LocalTime.now()); + Theme theme = new Theme("name", "desc", "thumb"); + Member member = new Member("name", "email", "password", Role.MEMBER); + + reservation = new Reservation(now, reservationTime, theme, member, ReservationStatus.CONFIRMED); + } + + @ParameterizedTest + @DisplayName("paymentKey가 null 또는 빈값이면 예외가 발생한다.") + @NullAndEmptySource + void invalidPaymentKey(String paymentKey) { + assertThatThrownBy(() -> new Payment("order-id", paymentKey, 10000L, reservation, OffsetDateTime.now())) + .isInstanceOf(RoomEscapeException.class); + } + + @ParameterizedTest + @DisplayName("orderId가 null 또는 빈값이면 예외가 발생한다.") + @NullAndEmptySource + void invalidOrderId(String orderId) { + assertThatThrownBy(() -> new Payment(orderId, "payment-key", 10000L, reservation, OffsetDateTime.now())) + .isInstanceOf(RoomEscapeException.class); + } + + @ParameterizedTest + @DisplayName("amount가 null 또는 0 이하면 예외가 발생한다.") + @CsvSource(value = {"null", "-1"}, nullValues = {"null"}) + void invalidOrderId(Long totalAmount) { + assertThatThrownBy( + () -> new Payment("orderId", "payment-key", totalAmount, reservation, OffsetDateTime.now())) + .isInstanceOf(RoomEscapeException.class); + } + + @ParameterizedTest + @DisplayName("Reservation이 null이면 예외가 발생한다.") + @NullSource + void invalidReservation(Reservation reservation) { + assertThatThrownBy(() -> new Payment("orderId", "payment-key", 10000L, reservation, OffsetDateTime.now())) + .isInstanceOf(RoomEscapeException.class); + } + + @ParameterizedTest + @DisplayName("승인 날짜가 null이면 예외가 발생한다.") + @NullSource + void invalidApprovedAt(OffsetDateTime approvedAt) { + assertThatThrownBy(() -> new Payment("orderId", "payment-key", 10000L, reservation, approvedAt)) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java b/src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java new file mode 100644 index 00000000..7ada8331 --- /dev/null +++ b/src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java @@ -0,0 +1,52 @@ +package roomescape.payment.dto.response; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; + +class PaymentCancelResponseDeserializerTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + SimpleModule simpleModule = new SimpleModule(); + simpleModule.addDeserializer(PaymentCancelResponse.class, new PaymentCancelResponseDeserializer()); + objectMapper.registerModule(simpleModule); + } + + @Test + @DisplayName("결제 취소 정보를 역직렬화하여 PaymentCancelResponse 객체를 생성한다.") + void deserialize() { + // given + String json = """ + { + "notUsedField": "notUsedValue", + "cancels": [ + { + "cancelStatus": "CANCELLED", + "cancelReason": "테스트 결제 취소", + "cancelAmount": 10000, + "canceledAt": "2021-07-01T10:10:10+09:00", + "notUsedField": "notUsedValue" + } + ] + }"""; + + // when + PaymentCancelResponse response = assertDoesNotThrow( + () -> objectMapper.readValue(json, PaymentCancelResponse.class)); + + // then + assertEquals("CANCELLED", response.cancelStatus()); + assertEquals("테스트 결제 취소", response.cancelReason()); + assertEquals(10000, response.cancelAmount()); + assertEquals("2021-07-01T10:10:10+09:00", response.canceledAt().toString()); + } +} diff --git a/src/test/java/roomescape/payment/service/PaymentServiceTest.java b/src/test/java/roomescape/payment/service/PaymentServiceTest.java new file mode 100644 index 00000000..29a7ad75 --- /dev/null +++ b/src/test/java/roomescape/payment/service/PaymentServiceTest.java @@ -0,0 +1,141 @@ +package roomescape.payment.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.payment.domain.repository.CanceledPaymentRepository; +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.dto.response.ReservationPaymentResponse; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; + +@SpringBootTest +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +class PaymentServiceTest { + + @Autowired + private PaymentService paymentService; + @Autowired + private ReservationRepository reservationRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private ReservationTimeRepository reservationTimeRepository; + @Autowired + private ThemeRepository themeRepository; + @Autowired + private CanceledPaymentRepository canceledPaymentRepository; + + @Test + @DisplayName("결제 정보를 저장한다.") + void savePayment() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "email@email.com", "password", Role.MEMBER)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + Reservation reservation = reservationRepository.save(new Reservation(date, time, theme, member, + ReservationStatus.CONFIRMED)); + + // when + ReservationPaymentResponse reservationPaymentResponse = paymentService.savePayment(paymentInfo, reservation); + + // then + assertThat(reservationPaymentResponse.reservation().id()).isEqualTo(reservation.getId()); + assertThat(reservationPaymentResponse.paymentKey()).isEqualTo(paymentInfo.paymentKey()); + } + + @Test + @DisplayName("예약 ID로 결제 정보를 제거하고, 결제 취소 테이블에 취소 정보를 저장한다.") + void cancelPaymentByAdmin() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "email@email.com", "password", Role.MEMBER)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + Reservation reservation = reservationRepository.save(new Reservation(date, time, theme, member, + ReservationStatus.CONFIRMED)); + + paymentService.savePayment(paymentInfo, reservation); + + // when + PaymentCancelRequest paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservation.getId()); + + // then + assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotEmpty(); + assertThat(paymentCancelRequest.paymentKey()).isEqualTo(paymentInfo.paymentKey()); + assertThat(paymentCancelRequest.cancelReason()).isEqualTo("고객 요청"); + assertThat(paymentCancelRequest.amount()).isEqualTo(10000L); + } + + @Test + @DisplayName("입력된 예약 ID에 대한 결제 정보가 없으면 예외가 발생한다.") + void cancelPaymentByAdminWithNonExistentReservationId() { + // given + Long nonExistentReservationId = 1L; + + // when + assertThatThrownBy(() -> paymentService.cancelPaymentByAdmin(nonExistentReservationId)) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("결제 취소 정보에 있는 취소 시간을 업데이트한다.") + void updateCanceledTime() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "email@email.com", "password", Role.MEMBER)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + Reservation reservation = reservationRepository.save(new Reservation(date, time, theme, member, + ReservationStatus.CONFIRMED)); + + paymentService.savePayment(paymentInfo, reservation); + paymentService.cancelPaymentByAdmin(reservation.getId()); + + // when + OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(2L); + paymentService.updateCanceledTime(paymentInfo.paymentKey(), canceledAt); + + // then + canceledPaymentRepository.findByPaymentKey(paymentInfo.paymentKey()) + .ifPresent(canceledPayment -> assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt)); + } + + @Test + @DisplayName("결제 취소 시간을 업데이트 할 때, 입력한 paymentKey가 존재하지 않으면 예외가 발생한다.") + void updateCanceledTimeWithNonExistentPaymentKey() { + // given + OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(2L); + + // when + assertThatThrownBy(() -> paymentService.updateCanceledTime("non-existent-payment-key", canceledAt)) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java b/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java new file mode 100644 index 00000000..ffba15b6 --- /dev/null +++ b/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java @@ -0,0 +1,620 @@ +package roomescape.reservation.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.payment.client.TossPaymentClient; +import roomescape.payment.domain.CanceledPayment; +import roomescape.payment.domain.Payment; +import roomescape.payment.domain.repository.CanceledPaymentRepository; +import roomescape.payment.domain.repository.PaymentRepository; +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.request.PaymentRequest; +import roomescape.payment.dto.response.PaymentCancelResponse; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.reservation.dto.request.AdminReservationRequest; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.request.WaitingRequest; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +public class ReservationControllerTest { + + @Autowired + private ReservationRepository reservationRepository; + @Autowired + private ReservationTimeRepository reservationTimeRepository; + @Autowired + private ThemeRepository themeRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private PaymentRepository paymentRepository; + @Autowired + private CanceledPaymentRepository canceledPaymentRepository; + + @MockBean + private TossPaymentClient paymentClient; + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("처음으로 등록하는 예약의 id는 1이다.") + void firstPost() { + String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + LocalTime time = LocalTime.of(17, 30); + LocalDate date = LocalDate.now().plusDays(1L); + + reservationTimeRepository.save(new ReservationTime(time)); + themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + + Map reservationParams = Map.of( + "date", date.toString(), + "timeId", "1", + "themeId", "1", + "paymentKey", "pk", + "orderId", "oi", + "amount", "1000", + "paymentType", "DEFAULT" + ); + + when(paymentClient.confirmPayment(any(PaymentRequest.class))).thenReturn( + new PaymentResponse("pk", "oi", OffsetDateTime.of(date, time, ZoneOffset.ofHours(9)), 1000L)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header("Cookie", accessTokenCookie) + .body(reservationParams) + .when().post("/reservations") + .then().log().all() + .statusCode(201) + .body("data.id", is(1)) + .header("Location", "/reservations/1"); + } + + @Test + @DisplayName("대기중인 예약을 취소한다.") + void cancelWaiting() { + // given + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + String accessTokenCookie = getAccessTokenCookieByLogin("email@email.com", "password"); + + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member1 = memberRepository.save(new Member("name1", "email1r@email.com", "password", Role.MEMBER)); + + // when + reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member1, + ReservationStatus.CONFIRMED)); + Reservation waiting = reservationRepository.save( + new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member, + ReservationStatus.WAITING)); + + // then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessTokenCookie) + .when().delete("/reservations/waiting/{id}", waiting.getId()) + .then().log().all() + .statusCode(204); + } + + @Test + @DisplayName("회원은 자신이 아닌 다른 회원의 예약을 취소할 수 없다.") + void cantCancelOtherMembersWaiting() { + // given + Member confirmedMember = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + String accessTokenCookie = getAccessTokenCookieByLogin("email@email.com", "password"); + + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member waitingMember = memberRepository.save(new Member("name1", "email1r@email.com", "password", Role.MEMBER)); + + // when + reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, confirmedMember, + ReservationStatus.CONFIRMED)); + Reservation waiting = reservationRepository.save( + new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, waitingMember, + ReservationStatus.WAITING)); + + // then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessTokenCookie) + .when().delete("/reservations/waiting/{id}", waiting.getId()) + .then().log().all() + .statusCode(404); + } + + @Test + @DisplayName("관리자 권한이 있으면 전체 예약정보를 조회할 수 있다.") + void readEmptyReservations() { + // given + String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + // when + reservationRepository.save( + new Reservation(LocalDate.now(), reservationTime, theme, member, ReservationStatus.CONFIRMED)); + reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member, + ReservationStatus.CONFIRMED)); + reservationRepository.save(new Reservation(LocalDate.now().plusDays(2), reservationTime, theme, member, + ReservationStatus.CONFIRMED)); + + // then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("data.reservations.size()", is(3)); + } + + @Test + @DisplayName("예약 취소는 관리자만 할 수 있다.") + void canRemoveMyReservation() { + // given + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + String accessTokenCookie = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); + + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Reservation reservation = reservationRepository.save( + new Reservation(LocalDate.now(), reservationTime, theme, member, ReservationStatus.CONFIRMED)); + + // when & then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessTokenCookie) + .when().delete("/reservations/" + reservation.getId()) + .then().log().all() + .statusCode(302); + } + + @Test + @DisplayName("관리자가 대기중인 예약을 거절한다.") + void denyWaiting() { + // given + String adminTokenCookie = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); + + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member confirmedMember = memberRepository.save(new Member("name1", "email@email.com", "password", Role.MEMBER)); + Member waitingMember = memberRepository.save(new Member("name1", "email1@email.com", "password", Role.MEMBER)); + + reservationRepository.save( + new Reservation(LocalDate.now(), reservationTime, theme, confirmedMember, ReservationStatus.CONFIRMED)); + Reservation waiting = reservationRepository.save( + new Reservation(LocalDate.now(), reservationTime, theme, waitingMember, ReservationStatus.WAITING)); + + // when & then + RestAssured.given().log().all() + .port(port) + .header("Cookie", adminTokenCookie) + .when().post("/reservations/waiting/{id}/deny", waiting.getId()) + .then().log().all() + .statusCode(204); + } + + @Test + @DisplayName("본인의 예약이 아니더라도 관리자 권한이 있으면 예약 정보를 삭제할 수 있다.") + void readReservationsSizeAfterPostAndDelete() { + // given + Member member = memberRepository.save(new Member("name", "admin@admin.com", "password", Role.ADMIN)); + String accessTokenCookie = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); + + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member anotherMember = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + Reservation reservation = reservationRepository.save( + new Reservation(LocalDate.now(), reservationTime, theme, anotherMember, ReservationStatus.CONFIRMED)); + + // when & then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessTokenCookie) + .when().delete("/reservations/" + reservation.getId()) + .then().log().all() + .statusCode(204); + } + + @ParameterizedTest + @MethodSource("requestValidateSource") + @DisplayName("예약 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.") + void validateBlankRequest(Map invalidRequestBody) { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(invalidRequestBody) + .header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a")) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + } + + private static Stream> requestValidateSource() { + return Stream.of( + Map.of("timeId", "1", + "themeId", "1"), + + Map.of("date", LocalDate.now().plusDays(1L).toString(), + "themeId", "1"), + + Map.of("date", LocalDate.now().plusDays(1L).toString(), + "timeId", "1"), + + Map.of("date", " ", + "timeId", "1", + "themeId", "1"), + + Map.of("date", LocalDate.now().plusDays(1L).toString(), + "timeId", " ", + "themeId", "1"), + + Map.of("date", LocalDate.now().plusDays(1L).toString(), + "timeId", "1", + "themeId", " ") + ); + } + + @Test + @DisplayName("예약 생성 시, 정수 요청 데이터에 문자가 입력되어오면 400 에러를 발생한다.") + void validateRequestDataFormat() { + Map invalidTypeRequestBody = Map.of( + "date", LocalDate.now().plusDays(1L).toString(), + "timeId", "1", + "themeId", "한글" + ); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a")) + .body(invalidTypeRequestBody) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + } + + @ParameterizedTest + @DisplayName("모든 예약 / 대기 중인 예약 / 현재 로그인된 회원의 예약 및 대기를 조회한다.") + @CsvSource(value = {"/reservations, reservations, 2", "/reservations/waiting, reservations, 1", + "/reservations-mine, myReservationResponses, 3"}, delimiter = ',') + void getAllReservations(String requestURI, String responseFieldName, int expectedSize) { + // given + LocalDate date = LocalDate.now().plusDays(1); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + ReservationTime time1 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(18, 30))); + ReservationTime time2 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(19, 30))); + + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.ADMIN)); + String accessToken = getAccessTokenCookieByLogin("email@email.com", "password"); + + // when : 예약은 2개, 예약 대기는 1개 조회되어야 한다. + reservationRepository.save(new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED)); + reservationRepository.save( + new Reservation(date, time1, theme, member, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); + reservationRepository.save(new Reservation(date, time2, theme, member, ReservationStatus.WAITING)); + + // then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessToken) + .when().get(requestURI) + .then().log().all() + .statusCode(200) + .body("data." + responseFieldName + ".size()", is(expectedSize)); + } + + @Test + @DisplayName("예약을 삭제할 때, 승인되었으나 결제 대기중인 예약은 결제 취소 없이 바로 삭제한다.") + void removeNotPaidReservation() { + // given + LocalDate date = LocalDate.now().plusDays(1); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); + + // when + Reservation saved = reservationRepository.save(new Reservation(date, time, theme, + memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)), + ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); + + // then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessToken) + .when().delete("/reservations/{id}", saved.getId()) + .then().log().all() + .statusCode(204); + } + + @Test + @DisplayName("이미 결제가 된 예약은 삭제 후 결제 취소를 요청한다.") + void removePaidReservation() { + // given + String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); + LocalDate date = LocalDate.now().plusDays(1); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + Reservation saved = reservationRepository.save( + new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED)); + Payment savedPayment = paymentRepository.save( + new Payment("pk", "oi", 1000L, saved, OffsetDateTime.now().minusHours(1L))); + + // when + when(paymentClient.cancelPayment(any(PaymentCancelRequest.class))) + .thenReturn(new PaymentCancelResponse("pk", "고객 요청", savedPayment.getTotalAmount(), + OffsetDateTime.now())); + + // then + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessToken) + .when().delete("/reservations/{id}", saved.getId()) + .then().log().all() + .statusCode(204); + + } + + @Test + @DisplayName("예약을 추가할 때, 결제 승인 이후에 예외가 발생하면 결제를 취소한 뒤 결제 취소 테이블에 취소 정보를 저장한다.") + void saveReservationWithCancelPayment() { + // given + LocalDateTime localDateTime = LocalDateTime.now().minusHours(1L).withNano(0); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + String accessToken = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); + + // when : 이전 날짜의 예약을 추가하여 결제 승인 이후 DB 저장 과정에서 예외를 발생시킨다. + String paymentKey = "pk"; + OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(1L).withNano(0); + OffsetDateTime approvedAt = OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(9)); + when(paymentClient.confirmPayment(any(PaymentRequest.class))) + .thenReturn(new PaymentResponse(paymentKey, "oi", approvedAt, 1000L)); + + when(paymentClient.cancelPayment(any(PaymentCancelRequest.class))) + .thenReturn(new PaymentCancelResponse(paymentKey, "고객 요청", 1000L, canceledAt)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header("Cookie", accessToken) + .body(new ReservationRequest(date, time.getId(), theme.getId(), "pk", "oi", 1000L, "DEFAULT")) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + + // then + Optional canceledPaymentOptional = canceledPaymentRepository.findByPaymentKey(paymentKey); + assertThat(canceledPaymentOptional).isNotNull(); + assertThat(canceledPaymentOptional.get().getCanceledAt()).isEqualTo(canceledAt); + assertThat(canceledPaymentOptional.get().getCancelReason()).isEqualTo("고객 요청"); + assertThat(canceledPaymentOptional.get().getCancelAmount()).isEqualTo(1000L); + assertThat(canceledPaymentOptional.get().getApprovedAt()).isEqualTo(approvedAt); + } + + @DisplayName("테마만을 이용하여 예약을 조회한다.") + @ParameterizedTest(name = "테마 ID={0}로 조회 시 {1}개의 예약이 조회된다.") + @CsvSource(value = {"1/4", "2/3"}, delimiter = '/') + @Sql({"/truncate.sql", "/test_search_data.sql"}) + void searchByTheme(String themeId, int expectedCount) { + RestAssured.given().log().all() + .port(port) + .param("themeId", themeId) + .param("memberId", "") + .param("dateFrom", "") + .param("dateTo", "") + .header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password")) + .when().get("/reservations/search") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .body("data.reservations.size()", is(expectedCount)); + } + + @DisplayName("시작 날짜만을 이용하여 예약을 조회한다.") + @ParameterizedTest(name = "오늘 날짜보다 {0}일 전인 날짜를 시작 날짜로 조회 시 {1}개의 예약이 조회된다.") + @CsvSource(value = {"1/1", "7/7"}, delimiter = '/') + @Sql({"/truncate.sql", "/test_search_data.sql"}) + void searchByFromDate(int minusDays, int expectedCount) { + RestAssured.given().log().all() + .port(port) + .param("themeId", "") + .param("memberId", "") + .param("dateFrom", LocalDate.now().minusDays(minusDays).toString()) + .param("dateTo", "") + .header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password")) + .when().get("/reservations/search") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .body("data.reservations.size()", is(expectedCount)); + } + + @DisplayName("종료 날짜만을 이용하여 예약을 조회한다..") + @ParameterizedTest(name = "오늘 날짜보다 {0}일 전인 날짜를 종료 날짜로 조회 시 {1}개의 예약이 조회된다.") + @CsvSource(value = {"1/7", "3/5", "7/1"}, delimiter = '/') + @Sql({"/truncate.sql", "/test_search_data.sql"}) + void searchByToDate(int minusDays, int expectedCount) { + RestAssured.given().log().all() + .port(port) + .param("themeId", "") + .param("memberId", "") + .param("dateFrom", "") + .param("dateTo", LocalDate.now().minusDays(minusDays).toString()) + .header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password")) + .when().get("/reservations/search") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .body("data.reservations.size()", is(expectedCount)); + } + + @Test + @DisplayName("예약 대기를 추가한다.") + void addWaiting() { + // given + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + Member member1 = memberRepository.save(new Member("name1", "email1@email.com", "password", Role.MEMBER)); + + String accessToken = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); + reservationRepository.save(new Reservation(date, time, theme, member1, ReservationStatus.CONFIRMED)); + + // when & then + RestAssured.given().log().all() + .port(port) + .contentType(ContentType.JSON) + .header("Cookie", accessToken) + .body(new WaitingRequest(date, time.getId(), theme.getId())) + .when().post("/reservations/waiting") + .then().log().all() + .statusCode(201) + .body("data.status", is("WAITING")); + } + + @Test + @DisplayName("대기중인 예약을 승인한다.") + void approveWaiting() { + // given + LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); + Reservation waiting = reservationRepository.save( + new Reservation(date, time, theme, member, ReservationStatus.WAITING)); + + // when + RestAssured.given().log().all() + .port(port) + .header("Cookie", accessToken) + .when().post("/reservations/waiting/{id}/approve", waiting.getId()) + .then().log().all() + .statusCode(200); + + // then + reservationRepository.findById(waiting.getId()) + .ifPresent(r -> assertThat(r.getReservationStatus()).isEqualTo( + ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); + + } + + private String getAccessTokenCookieByLogin(final String email, final String password) { + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } + + @Test + @DisplayName("관리자가 직접 예약을 추가한다.") + void addReservationByAdmin() { + // given + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + String adminAccessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); + + // when & then + RestAssured.given().log().all() + .port(port) + .contentType(ContentType.JSON) + .header("Cookie", adminAccessToken) + .body(new AdminReservationRequest(date, time.getId(), theme.getId(), member.getId())) + .when().post("/reservations/admin") + .then().log().all() + .statusCode(201); + } + + private String getAdminAccessTokenCookieByLogin(final String email, final String password) { + memberRepository.save(new Member("이름", email, password, Role.ADMIN)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } +} diff --git a/src/test/java/roomescape/reservation/controller/ReservationTimeControllerTest.java b/src/test/java/roomescape/reservation/controller/ReservationTimeControllerTest.java new file mode 100644 index 00000000..cefd9aff --- /dev/null +++ b/src/test/java/roomescape/reservation/controller/ReservationTimeControllerTest.java @@ -0,0 +1,247 @@ +package roomescape.reservation.controller; + +import static org.hamcrest.Matchers.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +public class ReservationTimeControllerTest { + + @Autowired + private ReservationTimeRepository reservationTimeRepository; + + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private MemberRepository memberRepository; + + @LocalServerPort + private int port; + + private final Map params = Map.of( + "startAt", "17:00" + ); + + @Test + @DisplayName("처음으로 등록하는 시간의 id는 1이다.") + void firstPost() { + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .body(params) + .when().post("/times") + .then().log().all() + .statusCode(201) + .body("data.id", is(1)) + .header("Location", "/times/1"); + } + + @Test + @DisplayName("아무 시간도 등록 하지 않은 경우, 시간 목록 조회 결과 개수는 0개이다.") + void readEmptyTimes() { + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password"); + + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/times") + .then().log().all() + .statusCode(200) + .body("data.times.size()", is(0)); + } + + @Test + @DisplayName("하나의 시간만 등록한 경우, 시간 목록 조회 결과 개수는 1개이다.") + void readTimesSizeAfterFirstPost() { + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .body(params) + .when().post("/times") + .then().log().all() + .statusCode(201) + .body("data.id", is(1)) + .header("Location", "/times/1"); + + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/times") + .then().log().all() + .statusCode(200) + .body("data.times.size()", is(1)); + } + + @Test + @DisplayName("하나의 시간만 등록한 경우, 시간 삭제 뒤 시간 목록 조회 결과 개수는 0개이다.") + void readTimesSizeAfterPostAndDelete() { + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .body(params) + .when().post("/times") + .then().log().all() + .statusCode(201) + .body("data.id", is(1)) + .header("Location", "/times/1"); + + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().delete("/times/1") + .then().log().all() + .statusCode(204); + + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/times") + .then().log().all() + .statusCode(200) + .body("data.times.size()", is(0)); + } + + @ParameterizedTest + @MethodSource("validateRequestDataFormatSource") + @DisplayName("예약 시간 생성 시, 시간 요청 데이터에 시간 포맷이 아닌 값이 입력되어오면 400 에러를 발생한다.") + void validateRequestDataFormat(Map request) { + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .body(request) + .when().post("/times") + .then().log().all() + .statusCode(400); + } + + static Stream> validateRequestDataFormatSource() { + return Stream.of( + Map.of( + "startAt", "24:59" + ), + Map.of( + "startAt", "hihi") + ); + } + + @ParameterizedTest + @MethodSource("validateBlankRequestSource") + @DisplayName("예약 시간 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.") + void validateBlankRequest(Map request) { + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .body(request) + .when().post("/times") + .then().log().all() + .statusCode(400); + } + + static Stream> validateBlankRequestSource() { + return Stream.of( + Map.of( + ), + Map.of( + "startAt", "" + ), + Map.of( + "startAt", " " + ) + ); + } + + private String getAdminAccessTokenCookieByLogin(String email, String password) { + memberRepository.save(new Member("이름", email, password, Role.ADMIN)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } + + @Test + @DisplayName("특정 날짜의 특정 테마 예약 현황을 조회한다.") + void readReservationByDateAndThemeId() { + // given + LocalDate today = LocalDate.now(); + ReservationTime reservationTime1 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 0))); + ReservationTime reservationTime2 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); + ReservationTime reservationTime3 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(18, 30))); + Theme theme = themeRepository.save(new Theme("테마명1", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + reservationRepository.save( + new Reservation(today.plusDays(1), reservationTime1, theme, member, ReservationStatus.CONFIRMED)); + reservationRepository.save( + new Reservation(today.plusDays(1), reservationTime2, theme, member, ReservationStatus.CONFIRMED)); + reservationRepository.save( + new Reservation(today.plusDays(1), reservationTime3, theme, member, ReservationStatus.CONFIRMED)); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a")) + .when().get("/times/filter?date={date}&themeId={themeId}", today.plusDays(1).toString(), theme.getId()) + .then().log().all() + .statusCode(200) + .body("data.reservationTimes.size()", is(3)); + } +} diff --git a/src/test/java/roomescape/reservation/domain/ReservationTest.java b/src/test/java/roomescape/reservation/domain/ReservationTest.java new file mode 100644 index 00000000..f66623e6 --- /dev/null +++ b/src/test/java/roomescape/reservation/domain/ReservationTest.java @@ -0,0 +1,55 @@ +package roomescape.reservation.domain; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.stream.Stream; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; + +public class ReservationTest { + + @ParameterizedTest + @MethodSource("validateConstructorParameterBlankSource") + @DisplayName("객체 생성 시, null 또는 공백이 존재하면 예외를 발생한다.") + void validateConstructorParameterBlank(LocalDate date, ReservationTime reservationTime, Theme theme, + Member member) { + + // when & then + Assertions.assertThatThrownBy( + () -> new Reservation(date, reservationTime, theme, member, ReservationStatus.CONFIRMED)) + .isInstanceOf(RoomEscapeException.class); + } + + static Stream validateConstructorParameterBlankSource() { + return Stream.of( + Arguments.of(null, + new ReservationTime(LocalTime.now().plusHours(1)), + new Theme("테마명", "설명", "썸네일URI"), + new Member("name", "email@email.com", "password", Role.MEMBER)), + Arguments.of( + LocalDate.now(), + null, + new Theme("테마명", "설명", "썸네일URI"), + new Member("name", "email@email.com", "password", Role.MEMBER)), + Arguments.of( + LocalDate.now(), + new ReservationTime(LocalTime.now().plusHours(1)), + null, + new Member("name", "email@email.com", "password", Role.MEMBER)), + Arguments.of( + LocalDate.now(), + new ReservationTime(LocalTime.now().plusHours(1)), + new Theme("테마명", "설명", "썸네일URI"), + null) + ); + } +} diff --git a/src/test/java/roomescape/reservation/domain/ReservationTimeTest.java b/src/test/java/roomescape/reservation/domain/ReservationTimeTest.java new file mode 100644 index 00000000..4cdd9dd4 --- /dev/null +++ b/src/test/java/roomescape/reservation/domain/ReservationTimeTest.java @@ -0,0 +1,19 @@ +package roomescape.reservation.domain; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import roomescape.system.exception.RoomEscapeException; + +class ReservationTimeTest { + + @Test + @DisplayName("객체 생성 시, null이 존재하면 예외를 발생한다.") + void validateConstructorParameterNull() { + + // when & then + Assertions.assertThatThrownBy(() -> new ReservationTime(null)) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/reservation/domain/repository/ReservationSearchSpecificationTest.java b/src/test/java/roomescape/reservation/domain/repository/ReservationSearchSpecificationTest.java new file mode 100644 index 00000000..62cb372f --- /dev/null +++ b/src/test/java/roomescape/reservation/domain/repository/ReservationSearchSpecificationTest.java @@ -0,0 +1,175 @@ +package roomescape.reservation.domain.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.jpa.domain.Specification; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; + +@DataJpaTest +class ReservationSearchSpecificationTest { + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private ReservationTimeRepository timeRepository; + + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private MemberRepository memberRepository; + + /** + * 시간은 모두 현재 시간(LocalTime.now()), 테마, 회원은 동일 확정된 예약은 오늘, 결제 대기인 예약은 어제, 대기 상태인 예약은 내일 + */ + // 현재 시간으로 확정 예약 + private Reservation reservation1; + // 확정되었으나 결제 대기인 하루 전 예약 + private Reservation reservation2; + // 대기 상태인 내일 예약 + private Reservation reservation3; + + @BeforeEach + void setUp() { + LocalDateTime dateTime = LocalDateTime.now(); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + ReservationTime time = timeRepository.save(new ReservationTime(dateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("name", "description", "thumbnail")); + + reservation1 = reservationRepository.save( + new Reservation(dateTime.toLocalDate(), time, theme, member, ReservationStatus.CONFIRMED)); + reservation2 = reservationRepository.save( + new Reservation(dateTime.toLocalDate().minusDays(1), time, theme, member, + ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); + reservation3 = reservationRepository.save( + new Reservation(dateTime.toLocalDate().plusDays(1), time, theme, member, ReservationStatus.WAITING)); + } + + @Test + @DisplayName("동일한 테마의 예약을 찾는다.") + void searchByThemeId() { + // given + Long themeId = reservation1.getTheme().getId(); + Specification spec = new ReservationSearchSpecification().sameThemeId(themeId).build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1, reservation2, reservation3); + } + + @Test + @DisplayName("동일한 회원의 예약을 찾는다.") + void searchByMemberId() { + // given + Long memberId = reservation1.getMember().getId(); + Specification spec = new ReservationSearchSpecification().sameMemberId(memberId).build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1, reservation2, reservation3); + } + + @Test + @DisplayName("동일한 시간의 예약을 찾는다.") + void searchByTimeId() { + // given + Long timeId = reservation1.getReservationTime().getId(); + Specification spec = new ReservationSearchSpecification().sameTimeId(timeId).build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1, reservation2, reservation3); + } + + @Test + @DisplayName("동일한 날짜의 예약을 찾는다.") + void searchByDate() { + // given + LocalDate date = reservation1.getDate(); + Specification spec = new ReservationSearchSpecification().sameDate(date).build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1); + } + + @Test + @DisplayName("확정 상태인 예약을 찾는다.") + void searchConfirmedReservation() { + // given + Specification spec = new ReservationSearchSpecification().confirmed().build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1, reservation2); + } + + @Test + @DisplayName("대기 중인 예약을 찾는다.") + void searchWaitingReservation() { + // given + Specification spec = new ReservationSearchSpecification().waiting().build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation3); + } + + @Test + @DisplayName("특정 날짜 이후의 예약을 찾는다.") + void searchDateStartFrom() { + // given : 어제 이후의 예약을 조회하면, 모든 예약이 조회되어야 한다. + LocalDate date = LocalDate.now().minusDays(1L); + Specification spec = new ReservationSearchSpecification().dateStartFrom(date).build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1, reservation2, reservation3); + } + + @Test + @DisplayName("특정 날짜 이전의 예약을 찾는다.") + void searchDateEndAt() { + // given : 내일 이전의 예약을 조회하면, 모든 예약이 조회되어야 한다. + LocalDate date = LocalDate.now().plusDays(1L); + Specification spec = new ReservationSearchSpecification().dateEndAt(date).build(); + + // when + List found = reservationRepository.findAll(spec); + + // then + assertThat(found).containsExactly(reservation1, reservation2, reservation3); + } +} diff --git a/src/test/java/roomescape/reservation/service/ReservationServiceTest.java b/src/test/java/roomescape/reservation/service/ReservationServiceTest.java new file mode 100644 index 00000000..59ecd913 --- /dev/null +++ b/src/test/java/roomescape/reservation/service/ReservationServiceTest.java @@ -0,0 +1,219 @@ +package roomescape.reservation.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.member.service.MemberService; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.request.WaitingRequest; +import roomescape.reservation.dto.response.ReservationResponse; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; +import roomescape.theme.service.ThemeService; + +@SpringBootTest +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +@Import({ReservationService.class, MemberService.class, ReservationTimeService.class, ThemeService.class}) +class ReservationServiceTest { + + @Autowired + ReservationTimeRepository reservationTimeRepository; + @Autowired + ReservationRepository reservationRepository; + @Autowired + ThemeRepository themeRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + private ReservationService reservationService; + + @Test + @DisplayName("예약을 추가할때 이미 예약이 존재하면 예외가 발생한다.") + void reservationAlreadyExistFail() { + // given + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member1 = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + Member member2 = memberRepository.save(new Member("name2", "email2@email.com", "password", Role.MEMBER)); + LocalDate date = LocalDate.now().plusDays(1L); + + // when + reservationService.addReservation( + new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId", + 1000L, "paymentType"), member2.getId()); + + // then + assertThatThrownBy(() -> reservationService.addReservation( + new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId", + 1000L, "paymentType"), member1.getId())) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("이미 예약한 멤버가 같은 테마에 대기를 신청하면 예외가 발생한다.") + void requestWaitWhenAlreadyReserveFail() { + // given + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + LocalDate date = LocalDate.now().plusDays(1L); + + // when + reservationService.addReservation( + new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId", + 1000L, "paymentType"), member.getId()); + + // then + assertThatThrownBy(() -> reservationService.addWaiting( + new WaitingRequest(date, reservationTime.getId(), theme.getId()), member.getId())) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("예약 대기를 두 번 이상 요청하면 예외가 발생한다.") + void requestWaitTwiceFail() { + // given + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + Member member1 = memberRepository.save(new Member("name1", "email1@email.com", "password", Role.MEMBER)); + LocalDate date = LocalDate.now().plusDays(1L); + + // when + reservationService.addReservation( + new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId", + 1000L, "paymentType"), member.getId()); + + reservationService.addWaiting( + new WaitingRequest(date, reservationTime.getId(), theme.getId()), member1.getId()); + + // then + assertThatThrownBy(() -> reservationService.addWaiting( + new WaitingRequest(date, reservationTime.getId(), theme.getId()), member1.getId())) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("이미 지난 날짜로 예약을 생성하면 예외가 발생한다.") + void beforeDateReservationFail() { + // given + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + LocalDate beforeDate = LocalDate.now().minusDays(1L); + + // when & then + assertThatThrownBy(() -> reservationService.addReservation( + new ReservationRequest(beforeDate, reservationTime.getId(), theme.getId(), "paymentKey", "orderId", + 1000L, "paymentType"), member.getId())) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("현재 날짜가 예약 당일이지만, 이미 지난 시간으로 예약을 생성하면 예외가 발생한다.") + void beforeTimeReservationFail() { + // given + LocalDateTime beforeTime = LocalDateTime.now().minusHours(1L).withNano(0); + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(beforeTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + // when & then + assertThatThrownBy(() -> reservationService.addReservation( + new ReservationRequest(beforeTime.toLocalDate(), reservationTime.getId(), theme.getId(), "paymentKey", + "orderId", 1000L, "paymentType"), member.getId())) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("존재하지 않는 회원이 예약을 생성하려고 하면 예외가 발생한다.") + void notExistMemberReservationFail() { + // given + LocalDateTime beforeTime = LocalDateTime.now().minusDays(1L).withNano(0); + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(beforeTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Long NotExistMemberId = 1L; + + // when & then + assertThatThrownBy(() -> reservationService.addReservation( + new ReservationRequest(beforeTime.toLocalDate(), reservationTime.getId(), theme.getId(), "paymentKey", + "orderId", 1000L, "paymentType"), + NotExistMemberId)) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("예약을 조회할 때 종료 날짜가 시작 날짜 이전이면 예외가 발생한다.") + void invalidDateRange() { + // given + LocalDate dateFrom = LocalDate.now().plusDays(1); + LocalDate dateTo = LocalDate.now(); + + // when & then + assertThatThrownBy(() -> reservationService.findFilteredReservations(null, null, dateFrom, dateTo)) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("대기중인 예약을 승인할 때, 기존에 예약이 존재하면 예외가 발생한다.") + void confirmWaitingWhenReservationExist() { + // given + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member admin = memberRepository.save(new Member("admin", "admin@email.com", "password", Role.ADMIN)); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + Member member1 = memberRepository.save(new Member("name1", "email1@email.com", "password", Role.MEMBER)); + + reservationService.addReservation( + new ReservationRequest(LocalDate.now().plusDays(1L), reservationTime.getId(), theme.getId(), + "paymentKey", "orderId", + 1000L, "paymentType"), member.getId()); + ReservationResponse waiting = reservationService.addWaiting( + new WaitingRequest(LocalDate.now().plusDays(1L), reservationTime.getId(), theme.getId()), + member1.getId()); + + // when & then + assertThatThrownBy(() -> reservationService.approveWaiting(waiting.id(), admin.getId())) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("대기중인 예약을 확정한다.") + void approveWaiting() { + // given + ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member admin = memberRepository.save(new Member("admin", "admin@email.com", "password", Role.ADMIN)); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + // when + ReservationResponse waiting = reservationService.addWaiting( + new WaitingRequest(LocalDate.now().plusDays(1L), reservationTime.getId(), theme.getId()), + member.getId()); + reservationService.approveWaiting(waiting.id(), admin.getId()); + + // then + Reservation confirmed = reservationRepository.findById(waiting.id()).get(); + assertThat(confirmed.getReservationStatus()).isEqualTo(ReservationStatus.CONFIRMED_PAYMENT_REQUIRED); + } +} diff --git a/src/test/java/roomescape/reservation/service/ReservationTimeServiceTest.java b/src/test/java/roomescape/reservation/service/ReservationTimeServiceTest.java new file mode 100644 index 00000000..5b3e57a9 --- /dev/null +++ b/src/test/java/roomescape/reservation/service/ReservationTimeServiceTest.java @@ -0,0 +1,88 @@ +package roomescape.reservation.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.reservation.dto.request.ReservationTimeRequest; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; + +@SpringBootTest +@Import(ReservationTimeService.class) +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +class ReservationTimeServiceTest { + + @Autowired + private ReservationTimeService reservationTimeService; + @Autowired + private ReservationTimeRepository reservationTimeRepository; + @Autowired + private ReservationRepository reservationRepository; + @Autowired + private ThemeRepository themeRepository; + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("중복된 예약 시간을 등록하는 경우 예외가 발생한다.") + void duplicateTimeFail() { + // given + reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + + // when & then + assertThatThrownBy(() -> reservationTimeService.addTime(new ReservationTimeRequest(LocalTime.of(12, 30)))) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("존재하지 않는 ID로 시간을 조회하면 예외가 발생한다.") + void findTimeByIdFail() { + // given + ReservationTime saved = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30))); + + // when + Long invalidTimeId = saved.getId() + 1; + + // when & then + assertThatThrownBy(() -> reservationTimeService.findTimeById(invalidTimeId)) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("삭제하려는 시간에 예약이 존재하면 예외를 발생한다.") + void usingTimeDeleteFail() { + // given + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); + ReservationTime reservationTime = reservationTimeRepository.save( + new ReservationTime(localDateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); + Member member = memberRepository.save(new Member("name", "email@email.com", "password", Role.MEMBER)); + + // when + reservationRepository.save(new Reservation(localDateTime.toLocalDate(), reservationTime, theme, member, + ReservationStatus.CONFIRMED)); + + // then + assertThatThrownBy(() -> reservationTimeService.removeTimeById(reservationTime.getId())) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java b/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java new file mode 100644 index 00000000..973ccf03 --- /dev/null +++ b/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java @@ -0,0 +1,157 @@ +package roomescape.reservation.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.payment.domain.repository.CanceledPaymentRepository; +import roomescape.payment.domain.repository.PaymentRepository; +import roomescape.payment.dto.request.PaymentCancelRequest; +import roomescape.payment.dto.response.PaymentResponse; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.domain.ReservationStatus; +import roomescape.reservation.domain.ReservationTime; +import roomescape.reservation.domain.repository.ReservationRepository; +import roomescape.reservation.domain.repository.ReservationTimeRepository; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.response.ReservationResponse; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; + +@SpringBootTest +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +class ReservationWithPaymentServiceTest { + + @Autowired + private ReservationWithPaymentService reservationWithPaymentService; + @Autowired + private ReservationRepository reservationRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private ReservationTimeRepository reservationTimeRepository; + @Autowired + private ThemeRepository themeRepository; + @Autowired + private PaymentRepository paymentRepository; + @Autowired + private CanceledPaymentRepository canceledPaymentRepository; + + @Test + @DisplayName("예약과 결제 정보를 추가한다.") + void addReservationWithPayment() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "email@email.com", "password", Role.MEMBER)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + ReservationRequest reservationRequest = new ReservationRequest(date, time.getId(), theme.getId(), "payment-key", + "order-id", 10000L, "NORMAL"); + + // when + ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment( + reservationRequest, paymentInfo, member.getId()); + + // then + reservationRepository.findById(reservationResponse.id()) + .ifPresent(reservation -> { + assertThat(reservation.getMember().getId()).isEqualTo(member.getId()); + assertThat(reservation.getTheme().getId()).isEqualTo(theme.getId()); + assertThat(reservation.getDate()).isEqualTo(date); + assertThat(reservation.getReservationTime().getId()).isEqualTo(time.getId()); + assertThat(reservation.getReservationStatus()).isEqualTo(ReservationStatus.CONFIRMED); + }); + paymentRepository.findByPaymentKey("payment-key") + .ifPresent(payment -> { + assertThat(payment.getReservation().getId()).isEqualTo(reservationResponse.id()); + assertThat(payment.getPaymentKey()).isEqualTo("payment-key"); + assertThat(payment.getOrderId()).isEqualTo("order-id"); + assertThat(payment.getTotalAmount()).isEqualTo(10000L); + }); + } + + @Test + @DisplayName("예약 ID를 이용하여 예약과 결제 정보를 제거하고, 결제 취소 정보를 저장한다.") + void removeReservationWithPayment() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "admin@email.com", "password", Role.ADMIN)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + ReservationRequest reservationRequest = new ReservationRequest(date, time.getId(), theme.getId(), "payment-key", + "order-id", 10000L, "NORMAL"); + + ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment( + reservationRequest, paymentInfo, member.getId()); + + // when + PaymentCancelRequest paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment( + reservationResponse.id(), member.getId()); + + // then + assertThat(paymentCancelRequest.cancelReason()).isEqualTo("고객 요청"); + assertThat(reservationRepository.findById(reservationResponse.id())).isEmpty(); + assertThat(paymentRepository.findByPaymentKey("payment-key")).isEmpty(); + assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotEmpty(); + } + + @Test + @DisplayName("결제 정보가 없으면 True를 반환한다.") + void isNotPaidReservation() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "admin@email.com", "password", Role.ADMIN)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + + Reservation saved = reservationRepository.save( + new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); + + // when + boolean result = reservationWithPaymentService.isNotPaidReservation(saved.getId()); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("결제 정보가 있으면 False를 반환한다.") + void isPaidReservation() { + // given + PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); + LocalDate date = localDateTime.toLocalDate(); + ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); + Member member = memberRepository.save(new Member("member", "admin@email.com", "password", Role.ADMIN)); + Theme theme = themeRepository.save(new Theme("name", "desc", "thumbnail")); + ReservationRequest reservationRequest = new ReservationRequest(date, time.getId(), theme.getId(), "payment-key", + "order-id", 10000L, "NORMAL"); + + ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment( + reservationRequest, paymentInfo, member.getId()); + + // when + boolean result = reservationWithPaymentService.isNotPaidReservation(reservationResponse.id()); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/roomescape/system/auth/controller/AuthControllerTest.java b/src/test/java/roomescape/system/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..fb3918e3 --- /dev/null +++ b/src/test/java/roomescape/system/auth/controller/AuthControllerTest.java @@ -0,0 +1,118 @@ +package roomescape.system.auth.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.hamcrest.Matchers.*; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class AuthControllerTest { + + @Autowired + private MemberRepository memberRepository; + + @LocalServerPort + private int port; + + @Test + @DisplayName("로그인에 성공하면 JWT accessToken을 응답 받는다.") + void getJwtAccessTokenWhenlogin() { + // given + String email = "test@email.com"; + String password = "12341234"; + memberRepository.save(new Member("이름", email, password, Role.MEMBER)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + // when + Map cookies = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookies(); + + // then + assertThat(cookies.get("accessToken")).isNotNull(); + } + + @Test + @DisplayName("로그인 검증 시, 회원의 name을 응답 받는다.") + void checkLogin() { + // given + String email = "test@test.com"; + String password = "12341234"; + String accessTokenCookie = getAccessTokenCookieByLogin(email, password); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .header("cookie", accessTokenCookie) + .when().get("/login/check") + .then() + .body("data.name", is("이름")); + } + + @Test + @DisplayName("로그인 없이 검증요청을 보내면 401 Unauthorized 를 응답한다.") + void checkLoginFailByNotAuthorized() { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .when().get("/login/check") + .then() + .statusCode(401); + } + + @Test + @DisplayName("로그아웃 요청 시, accessToken 쿠키가 삭제된다.") + void checkLogout() { + // given + String accessToken = getAccessTokenCookieByLogin("email@email.com", "password"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header("cookie", accessToken) + .when().post("/logout") + .then() + .statusCode(200) + .cookie("accessToken", ""); + } + + private String getAccessTokenCookieByLogin(final String email, final String password) { + memberRepository.save(new Member("이름", email, password, Role.ADMIN)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } +} diff --git a/src/test/java/roomescape/system/auth/service/AuthServiceTest.java b/src/test/java/roomescape/system/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..bf8a5af8 --- /dev/null +++ b/src/test/java/roomescape/system/auth/service/AuthServiceTest.java @@ -0,0 +1,65 @@ +package roomescape.system.auth.service; + +import static org.assertj.core.api.Assertions.*; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.member.service.MemberService; +import roomescape.system.auth.dto.LoginRequest; +import roomescape.system.auth.jwt.JwtHandler; +import roomescape.system.auth.jwt.dto.TokenDto; +import roomescape.system.exception.RoomEscapeException; + +@SpringBootTest +@Import({AuthService.class, JwtHandler.class, MemberService.class}) +class AuthServiceTest { + + @Autowired + private AuthService authService; + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("로그인 성공시 JWT accessToken 을 반환한다.") + void loginSuccess() { + // given + Member member = memberRepository.save(new Member("이름", "test@test.com", "12341234", Role.MEMBER)); + + // when + TokenDto response = authService.login(new LoginRequest(member.getEmail(), member.getPassword())); + + // then + assertThat(response.accessToken()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 회원 email 또는 password로 로그인하면 예외가 발생한다.") + void loginFailByNotExistMemberInfo() { + // given + String notExistEmail = "invalid@test.com"; + String notExistPassword = "invalid1234"; + + // when & then + Assertions.assertThatThrownBy(() -> authService.login(new LoginRequest(notExistEmail, notExistPassword))) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("존재하지 않는 회원의 memberId로 로그인 여부를 체크하면 예외가 발생한다.") + void checkLoginFailByNotExistMemberInfo() { + // given + Long notExistMemberId = (long)(memberRepository.findAll().size() + 1); + + // when & then + Assertions.assertThatThrownBy(() -> authService.checkLogin(notExistMemberId)) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/system/config/JacksonConfigTest.java b/src/test/java/roomescape/system/config/JacksonConfigTest.java new file mode 100644 index 00000000..2d638aa3 --- /dev/null +++ b/src/test/java/roomescape/system/config/JacksonConfigTest.java @@ -0,0 +1,78 @@ +package roomescape.system.config; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +class JacksonConfigTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + JacksonConfig jacksonConfig = new JacksonConfig(); + objectMapper = jacksonConfig.objectMapper(); + } + + @DisplayName("날짜는 yyyy-MM-dd 형식으로 직렬화 된다.") + @Test + void dateSerialize() throws JsonProcessingException { + // given + LocalDate date = LocalDate.parse("2021-07-01"); + + // when + String json = objectMapper.writeValueAsString(date); + LocalDate actual = objectMapper.readValue(json, LocalDate.class); + + // then + assertThat(actual.toString()).isEqualTo("2021-07-01"); + } + + @DisplayName("시간은 HH:mm 형식으로 직렬화된다.") + @Test + void timeSerialize() throws JsonProcessingException { + // given + LocalTime time = LocalTime.parse("12:30:00"); + + // when + String json = objectMapper.writeValueAsString(time); + LocalTime actual = objectMapper.readValue(json, LocalTime.class); + + // then + assertThat(actual.toString()).isEqualTo("12:30"); + } + + @DisplayName("yyyy-MM-dd 형식의 문자열은 LocalDate로 역직렬화된다.") + @Test + void dateDeserialize() throws JsonProcessingException { + // given + String json = "\"2021-07-01\""; + + // when + LocalDate actual = objectMapper.readValue(json, LocalDate.class); + + // then + assertThat(actual).isEqualTo(LocalDate.of(2021, 7, 1)); + } + + @DisplayName("HH:mm 형식의 문자열은 LocalTime으로 역직렬화된다.") + @Test + void timeDeserialize() throws JsonProcessingException { + // given + String json = "\"12:30\""; + + // when + LocalTime actual = objectMapper.readValue(json, LocalTime.class); + + // then + assertThat(actual).isEqualTo(LocalTime.of(12, 30)); + } +} diff --git a/src/test/java/roomescape/theme/controller/ThemeControllerTest.java b/src/test/java/roomescape/theme/controller/ThemeControllerTest.java new file mode 100644 index 00000000..8eba3ab3 --- /dev/null +++ b/src/test/java/roomescape/theme/controller/ThemeControllerTest.java @@ -0,0 +1,184 @@ +package roomescape.theme.controller; + +import static org.hamcrest.Matchers.*; + +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) +class ThemeControllerTest { + + @LocalServerPort + private int port; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("모든 테마 정보를 조회한다.") + void readThemes() { + String email = "admin@test.com"; + String password = "12341234"; + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .when().get("/themes") + .then().log().all() + .statusCode(200) + .body("data.themes.size()", is(0)); + } + + @Test + @DisplayName("테마를 추가한다.") + void createThemes() { + String email = "admin@test.com"; + String password = "12341234"; + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + + Map params = Map.of( + "name", "테마명", + "description", "설명", + "thumbnail", "http://testsfasdgasd.com" + ); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .body(params) + .when().post("/themes") + .then().log().all() + .statusCode(201) + .body("data.id", is(1)) + .header("Location", "/themes/1"); + } + + @Test + @DisplayName("테마를 삭제한다.") + void deleteThemes() { + String email = "admin@test.com"; + String password = "12341234"; + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + + Map params = Map.of( + "name", "테마명", + "description", "설명", + "thumbnail", "http://testsfasdgasd.com" + ); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .body(params) + .when().post("/themes") + .then().log().all() + .statusCode(201) + .body("data.id", is(1)) + .header("Location", "/themes/1"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .when().delete("/themes/1") + .then().log().all() + .statusCode(204); + } + + /* + * reservationData DataSet ThemeID 별 reservation 개수 + * 5,4,2,5,2,3,1,1,1,1,1 + * 예약 수 내림차순 + ThemeId 오름차순 정렬 순서 + * 1, 4, 2, 6, 3, 5, 7, 8, 9, 10 + */ + @Test + @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.") + @Sql(scripts = {"/truncate.sql", "/reservationData.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) + void readTop10ThemesDescOrder() { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .when().get("/themes/most-reserved-last-week?count=10") + .then().log().all() + .statusCode(200) + .body("data.themes.size()", is(10)) + .body("data.themes.id", contains(1, 4, 2, 6, 3, 5, 7, 8, 9, 10)); + } + + @ParameterizedTest + @MethodSource("requestValidateSource") + @DisplayName("테마 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.") + void validateBlankRequest(Map invalidRequestBody) { + String email = "admin@test.com"; + String password = "12341234"; + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(new Header("Cookie", adminAccessTokenCookie)) + .port(port) + .body(invalidRequestBody) + .when().post("/themes") + .then().log().all() + .statusCode(400); + } + + static Stream> requestValidateSource() { + return Stream.of( + Map.of( + "name", "테마명", + "thumbnail", "http://testsfasdgasd.com" + ), + Map.of( + "name", "", + "description", "설명", + "thumbnail", "http://testsfasdgasd.com" + ), + Map.of( + "name", " ", + "description", "설명", + "thumbnail", "http://testsfasdgasd.com" + ) + ); + } + + private String getAdminAccessTokenCookieByLogin(final String email, final String password) { + memberRepository.save(new Member("이름", email, password, Role.ADMIN)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } +} diff --git a/src/test/java/roomescape/theme/service/ThemeServiceTest.java b/src/test/java/roomescape/theme/service/ThemeServiceTest.java new file mode 100644 index 00000000..be91c3ca --- /dev/null +++ b/src/test/java/roomescape/theme/service/ThemeServiceTest.java @@ -0,0 +1,164 @@ +package roomescape.theme.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; +import roomescape.member.service.MemberService; +import roomescape.reservation.dto.request.ReservationRequest; +import roomescape.reservation.dto.request.ReservationTimeRequest; +import roomescape.reservation.dto.response.ReservationTimeResponse; +import roomescape.reservation.service.ReservationService; +import roomescape.reservation.service.ReservationTimeService; +import roomescape.system.exception.RoomEscapeException; +import roomescape.theme.domain.Theme; +import roomescape.theme.domain.repository.ThemeRepository; +import roomescape.theme.dto.ThemeRequest; +import roomescape.theme.dto.ThemeResponse; +import roomescape.theme.dto.ThemesResponse; + +@DataJpaTest +@Import({ReservationTimeService.class, ReservationService.class, MemberService.class, ThemeService.class}) +@Sql(scripts = "/truncate.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class ThemeServiceTest { + + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private ThemeService themeService; + + @Autowired + private ReservationTimeService reservationTimeService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReservationService reservationService; + + @Test + @DisplayName("테마를 조회한다.") + void findThemeById() { + // given + Theme theme = themeRepository.save(new Theme("name", "description", "thumbnail")); + + // when + Theme foundTheme = themeService.findThemeById(theme.getId()); + + // then + assertThat(foundTheme).isEqualTo(theme); + } + + @Test + @DisplayName("존재하지 않는 ID로 테마를 조회하면 예외가 발생한다.") + void findThemeByNotExistId() { + // given + Theme theme = themeRepository.save(new Theme("name", "description", "thumbnail")); + + // when + Long notExistId = theme.getId() + 1; + + // then + assertThatThrownBy(() -> themeService.findThemeById(notExistId)) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("모든 테마를 조회한다.") + void findAllThemes() { + // given + Theme theme = themeRepository.save(new Theme("name", "description", "thumbnail")); + Theme theme1 = themeRepository.save(new Theme("name1", "description1", "thumbnail1")); + + // when + ThemesResponse found = themeService.findAllThemes(); + + // then + assertThat(found.themes()).extracting("id").containsExactly(theme.getId(), theme1.getId()); + } + + @Test + @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.") + @Sql({"/truncate.sql", "/reservationData.sql"}) + void getMostReservedThemesByCount() { + // given + LocalDate today = LocalDate.now(); + + // when + List found = themeService.getMostReservedThemesByCount(10).themes(); + + // then : 11번 테마는 조회되지 않아야 한다. + assertThat(found).extracting("id").containsExactly(1L, 4L, 2L, 6L, 3L, 5L, 7L, 8L, 9L, 10L); + } + + @Test + @DisplayName("테마를 추가한다.") + void addTheme() { + // given + ThemeResponse themeResponse = themeService.addTheme(new ThemeRequest("name", "description", "thumbnail")); + + // when + Theme found = themeRepository.findById(themeResponse.id()).orElse(null); + + // then + assertThat(found).isNotNull(); + } + + @Test + @DisplayName("테마를 추가할 때 같은 이름의 테마가 존재하면 예외가 발생한다. ") + void addDuplicateTheme() { + // given + ThemeResponse themeResponse = themeService.addTheme(new ThemeRequest("name", "description", "thumbnail")); + + // when + ThemeRequest invalidRequest = new ThemeRequest(themeResponse.name(), "description", "thumbnail"); + + // then + assertThatThrownBy(() -> themeService.addTheme(invalidRequest)) + .isInstanceOf(RoomEscapeException.class); + } + + @Test + @DisplayName("테마를 삭제한다.") + void removeThemeById() { + // given + Theme theme = themeRepository.save(new Theme("name", "description", "thumbnail")); + + // when + themeService.removeThemeById(theme.getId()); + + // then + assertThat(themeRepository.findById(theme.getId())).isEmpty(); + } + + @Test + @DisplayName("예약이 존재하는 테마를 삭제하면 예외가 발생한다.") + void removeReservedTheme() { + // given + LocalDateTime dateTime = LocalDateTime.now().plusDays(1); + ReservationTimeResponse time = reservationTimeService.addTime( + new ReservationTimeRequest(dateTime.toLocalTime())); + Theme theme = themeRepository.save(new Theme("name", "description", "thumbnail")); + Member member = memberRepository.save(new Member("member", "password", "name", Role.MEMBER)); + reservationService.addReservation( + new ReservationRequest(dateTime.toLocalDate(), time.id(), theme.getId(), "paymentKey", "orderId", 1000L, + "NORMAL"), member.getId()); + + // when & then + assertThatThrownBy(() -> themeService.removeThemeById(theme.getId())) + .isInstanceOf(RoomEscapeException.class); + } +} diff --git a/src/test/java/roomescape/view/controller/AdminPageControllerTest.java b/src/test/java/roomescape/view/controller/AdminPageControllerTest.java new file mode 100644 index 00000000..f1875809 --- /dev/null +++ b/src/test/java/roomescape/view/controller/AdminPageControllerTest.java @@ -0,0 +1,219 @@ +package roomescape.view.controller; + +import java.util.Map; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class AdminPageControllerTest { + + @Autowired + private MemberRepository memberRepository; + + @LocalServerPort + private int port; + + @Test + @DisplayName("관리자 권한이 있는 유저가 /admin 으로 GET 요청을 보내면 어드민 페이지와 200 OK 를 받는다.") + void getAdminPageHasRole() { + // given + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/admin") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("관리자 권한이 없는 유저가 /admin 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getAdminPageHasNotRole() { + // given + String accessTokenCookie = getAccessTokenCookieByLogin("member@member.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/admin") + .then().log().all() + .statusCode(200) + .body(Matchers.containsString("Login")); + } + + @Test + @DisplayName("/admin/reservation 으로 GET 요청을 보내면 어드민 예약 관리 페이지와 200 OK 를 받는다.") + void getAdminReservationPageHasRole() { + // given + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/admin/reservation") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("관리자 권한이 없는 유저가 /admin/reservation 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getAdminReservationPageHasNotRole() { + // given + String accessTokenCookie = getAccessTokenCookieByLogin("member@member.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/admin/reservation") + .then().log().all() + .statusCode(200) + .body(Matchers.containsString("Login")); + } + + @Test + @DisplayName("/admin/time 으로 GET 요청을 보내면 어드민 예약 시간 관리 페이지와 200 OK 를 받는다.") + void getAdminTimePageHasRole() { + // given + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/admin/time") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("관리자 권한이 없는 유저가 /admin/time 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getAdminTimePageHasNotRole() { + // given + String accessTokenCookie = getAccessTokenCookieByLogin("member@member.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/admin/time") + .then().log().all() + .statusCode(200) + .body(Matchers.containsString("Login")); + } + + @Test + @DisplayName("관리자 권한이 있는 유저가 /admin/theme 으로 GET 요청을 보내면 어드민 테마 관리 페이지와 200 OK 를 받는다.") + void getAdminThemePageHasRole() { + // given + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/admin/theme") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("관리자 권한이 없는 유저가 /admin/theme 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getAdminThemePageHasNotRole() { + // given + String accessTokenCookie = getAccessTokenCookieByLogin("member@member.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/admin/theme") + .then().log().all() + .statusCode(200) + .body(Matchers.containsString("Login")); + } + + @Test + @DisplayName("관리자 권한이 있는 유저가 /admin/waiting 으로 GET 요청을 보내면 어드민 대기 관리 페이지와 200 OK 를 받는다.") + void getAdminWatingPage() { + // given + String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("admin@email.com", "12341234"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", adminAccessTokenCookie)) + .when().get("/admin/waiting") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("관리자 권한이 없는 유저가 /admin/waiting 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getAdminWaitingPageHasNotRole() { + // given + String accessTokenCookie = getAccessTokenCookieByLogin("member@email.com", "member"); + + // when & then + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", accessTokenCookie)) + .when().get("/admin/waiting") + .then().log().all() + .statusCode(200) + .body(Matchers.containsString("Login")); + } + + private String getAdminAccessTokenCookieByLogin(String email, String password) { + memberRepository.save(new Member("이름", email, password, Role.ADMIN)); + + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } + + private String getAccessTokenCookieByLogin(String email, String password) { + memberRepository.save(new Member("name", email, password, Role.MEMBER)); + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } +} diff --git a/src/test/java/roomescape/view/controller/AuthPageControllerTest.java b/src/test/java/roomescape/view/controller/AuthPageControllerTest.java new file mode 100644 index 00000000..22dd1b62 --- /dev/null +++ b/src/test/java/roomescape/view/controller/AuthPageControllerTest.java @@ -0,0 +1,25 @@ +package roomescape.view.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import io.restassured.RestAssured; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AuthPageControllerTest { + + @LocalServerPort + private int port; + + @Test + @DisplayName("/login 으로 GET 요청을 보내면 login 페이지와 200 OK 를 받는다.") + void getMainPage() { + RestAssured.given().log().all() + .port(port) + .when().get("/login") + .then().log().all() + .statusCode(200); + } +} diff --git a/src/test/java/roomescape/view/controller/ClientPageControllerTest.java b/src/test/java/roomescape/view/controller/ClientPageControllerTest.java new file mode 100644 index 00000000..a7358bb0 --- /dev/null +++ b/src/test/java/roomescape/view/controller/ClientPageControllerTest.java @@ -0,0 +1,102 @@ +package roomescape.view.controller; + +import static org.hamcrest.Matchers.*; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.http.Header; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.member.domain.repository.MemberRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = "/truncate.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class ClientPageControllerTest { + + @Autowired + private MemberRepository memberRepository; + + @LocalServerPort + private int port; + + @Test + @DisplayName("/ 으로 GET 요청을 보내면 index 페이지와 200 OK 를 받는다.") + void getMainPage() { + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", getAccessTokenCookieByLogin("email@email.com", "password"))) + .when().get("/") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("/reservation 으로 GET 요청을 보내면 방탈출 예약 페이지와 200 OK 를 받는다.") + void getReservationPage() { + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", getAccessTokenCookieByLogin("email@email.com", "password"))) + .when().get("/reservation") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("로그인 없이 /reservation 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getReservationPageWithoutLogin() { + RestAssured.given().log().all() + .port(port) + .when().get("/reservation") + .then().log().all() + .statusCode(200) + .body(containsString("Login")); + } + + @Test + @DisplayName("/reservation-mine 으로 GET 요청을 보내면 방탈출 예약 페이지와 200 OK 를 받는다.") + void getMyReservationPage() { + RestAssured.given().log().all() + .port(port) + .header(new Header("Cookie", getAccessTokenCookieByLogin("email@email.com", "password"))) + .when().get("/reservation-mine") + .then().log().all() + .statusCode(200); + } + + @Test + @DisplayName("로그인 없이 /reservation-mine 으로 GET 요청을 보내면 로그인 페이지로 리다이렉트 된다.") + void getMyReservationPageWithoutLogin() { + RestAssured.given().log().all() + .port(port) + .when().get("/reservation-mine") + .then().log().all() + .statusCode(200) + .body(containsString("Login")); + } + + private String getAccessTokenCookieByLogin(String email, String password) { + memberRepository.save(new Member("name", email, password, Role.MEMBER)); + Map loginParams = Map.of( + "email", email, + "password", password + ); + + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .port(port) + .body(loginParams) + .when().post("/login") + .then().log().all().extract().cookie("accessToken"); + + return "accessToken=" + accessToken; + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 00000000..c9b0197a --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,27 @@ +spring: + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + ddl-auto: create-drop + defer-datasource-initialization: true + sql: + init: + data-locations: + h2: + console: + enabled: true + path: /h2-console + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:database-test + username: sa + password: + +security: + jwt: + token: + secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi + access: + expire-length: 1800000 # 30 분 diff --git a/src/test/resources/reservationData.sql b/src/test/resources/reservationData.sql new file mode 100644 index 00000000..b5a4c521 --- /dev/null +++ b/src/test/resources/reservationData.sql @@ -0,0 +1,194 @@ +INSERT INTO member (name, password, email, role) +VALUES ('이름', '12341234', 'test@test.com', 'MEMBER'); +INSERT INTO member (name, password, email, role) +VALUES ('관리자', '12341234', 'admin@admin.com', 'ADMIN'); + + +-- 테마 목록 : 11개 +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마1', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마2', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마3', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마4', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마5', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마6', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마7', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마8', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마9', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마10', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); +INSERT INTO theme (name, description, thumbnail) +VALUES ('테마11', '재밌는 테마입니다', + 'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE'); + +-- 예약 시간 목록 : 5개 +INSERT INTO reservation_time (start_at) +VALUES ('08:00'); +INSERT INTO reservation_time (start_at) +VALUES ('10:00'); +INSERT INTO reservation_time (start_at) +VALUES ('13:00'); +INSERT INTO reservation_time (start_at) +VALUES ('21:00'); +INSERT INTO reservation_time (start_at) +VALUES ('23:00'); + +-- 5,4,2,5,2,3,1,1,1,1,1 +-- 내림차순 정렬 ID : 4/1, 2, 6, 3/5, 7/8/9/10/11 + +-- 테마 1 예약 목록 : 5개 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 1, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 1, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 1, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 4, 1, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 1, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-1', 'paymentKey-1', 10000, 1, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-2', 'paymentKey-2', 20000, 2, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-3', 'paymentKey-3', 30000, 3, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-4', 'paymentKey-4', 40000, 4, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-5', 'paymentKey-5', 50000, 5, CURRENT_DATE); + +-- 테마 2 예약 목록 : 4개 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 2, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 2, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 2, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 4, 2, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-6', 'paymentKey-6', 50000, 6, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-7', 'paymentKey-7', 50000, 7, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-8', 'paymentKey-8', 50000, 8, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-9', 'paymentKey-9', 50000, 9, CURRENT_DATE); + + +-- 테마 3 예약 목록 : 2개 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 3, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 3, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-10', 'paymentKey-10', 50000, 10, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-11', 'paymentKey-11', 50000, 11, CURRENT_DATE); + +-- 테마 4 예약 목록 : 5개 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 4, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 4, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 4, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 4, 4, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 4, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-12', 'paymentKey-12', 50000, 12, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-13', 'paymentKey-13', 50000, 13, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-14', 'paymentKey-14', 50000, 14, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-15', 'paymentKey-15', 50000, 15, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-16', 'paymentKey-16', 50000, 16, CURRENT_DATE); + +-- 테마 5 예약 목록 : 2개 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 5, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 5, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-17', 'paymentKey-17', 50000, 17, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-18', 'paymentKey-18', 50000, 18, CURRENT_DATE); + +-- 테마 6 예약 목록 : 3개 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 6, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 6, 1, 'CONFIRMED'); +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 6, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-19', 'paymentKey-19', 50000, 19, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-20', 'paymentKey-20', 50000, 20, CURRENT_DATE); +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-21', 'paymentKey-21', 50000, 21, CURRENT_DATE); + +-- 테마 7 예약 목록 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 7, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-22', 'paymentKey-22', 50000, 22, CURRENT_DATE); + +-- 테마 8 예약 목록 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 8, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-23', 'paymentKey-23', 50000, 23, CURRENT_DATE); + + +-- 테마 9 예약 목록 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 9, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-24', 'paymentKey-24', 50000, 24, CURRENT_DATE); + +-- 테마 10 예약 목록 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 10, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-25', 'paymentKey-25', 50000, 25, CURRENT_DATE); + +-- 테마 11 예약 목록 +INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status) +VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 11, 1, 'CONFIRMED'); + +insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at) +values ('orderId-26', 'paymentKey-26', 50000, 26, CURRENT_DATE); diff --git a/src/test/resources/test_search_data.sql b/src/test/resources/test_search_data.sql new file mode 100644 index 00000000..c288d560 --- /dev/null +++ b/src/test/resources/test_search_data.sql @@ -0,0 +1,41 @@ +-- 관리자가 특정 조건에 해당되는 예약을 조회하는 테스트에서만 사용되는 데이터입니다. +insert into reservation_time(start_at) +values ('15:00'); + +insert into theme(name, description, thumbnail) +values ('테스트1', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg'); +insert into theme(name, description, thumbnail) +values ('테스트2', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg'); + +insert into member(name, email, password, role) +values ('어드민', 'a@a.a', 'a', 'ADMIN'); +insert into member(name, email, password, role) +values ('1호', '1@1.1', '1', 'MEMBER'); + +-- 예약 +-- 시간은 같은 시간으로, 날짜는 어제부터 7일 전까지 +-- memberId = 1인 멤버는 3개의 예약, memberId = 2인 멤버는 4개의 예약이 있음 +-- themeId = 1인 테마는 4개의 예약, themeId = 2인 테마는 3개의 예약이 있음 +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -1, CURRENT_DATE()), 1, 1, 1, 'CONFIRMED'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -2, CURRENT_DATE()), 1, 1, 1, 'CONFIRMED'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -3, CURRENT_DATE()), 1, 1, 1, 'CONFIRMED'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -4, CURRENT_DATE()), 1, 1, 2, 'CONFIRMED'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -5, CURRENT_DATE()), 1, 2, 2, 'CONFIRMED'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -6, CURRENT_DATE()), 1, 2, 2, 'CONFIRMED'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', -7, CURRENT_DATE()), 1, 2, 2, 'CONFIRMED'); + +-- 예약 대기 +-- 예약 대기는 조회되면 안됨. +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', 7, CURRENT_DATE()), 1, 1, 1, 'WAITING'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', 8, CURRENT_DATE()), 1, 1, 1, 'WAITING'); +insert into reservation(date, time_id, theme_id, member_id, reservation_status) +values (DATEADD('DAY', 9, CURRENT_DATE()), 1, 1, 2, 'WAITING'); diff --git a/src/test/resources/truncate.sql b/src/test/resources/truncate.sql new file mode 100644 index 00000000..cd541e0e --- /dev/null +++ b/src/test/resources/truncate.sql @@ -0,0 +1,25 @@ +DELETE +FROM payment; +DELETE +FROM canceled_payment; +DELETE +FROM reservation; +DELETE +FROM reservation_time; +DELETE +FROM theme; +DELETE +FROM member; + +ALTER TABLE payment + ALTER COLUMN id RESTART WITH 1; +ALTER TABLE canceled_payment + ALTER COLUMN id RESTART WITH 1; +ALTER TABLE reservation + ALTER COLUMN id RESTART WITH 1; +ALTER TABLE reservation_time + ALTER COLUMN id RESTART WITH 1; +ALTER TABLE theme + ALTER COLUMN id RESTART WITH 1; +ALTER TABLE member + ALTER COLUMN id RESTART WITH 1;