From 04bf97518e8821e0c58f0a34a454801b0f3bc1ff Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 12 Jul 2025 13:02:09 +0900 Subject: [PATCH] load origin project files --- .gitignore | 77 +-- LICENSE | 21 + build.gradle | 34 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59821 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 234 +++++++ gradlew.bat | 89 +++ .../roomescape/RoomescapeApplication.java | 15 + .../member/controller/MemberController.java | 37 ++ .../java/roomescape/member/domain/Member.java | 98 +++ .../java/roomescape/member/domain/Role.java | 6 + .../domain/repository/MemberRepository.java | 12 + .../roomescape/member/dto/MemberResponse.java | 14 + .../member/dto/MembersResponse.java | 11 + .../member/service/MemberService.java | 47 ++ .../roomescape/payment/PaymentConfig.java | 38 ++ .../payment/client/PaymentProperties.java | 29 + .../payment/client/TossPaymentClient.java | 98 +++ .../payment/domain/CanceledPayment.java | 67 ++ .../roomescape/payment/domain/Payment.java | 108 +++ .../repository/CanceledPaymentRepository.java | 12 + .../domain/repository/PaymentRepository.java | 14 + .../dto/request/PaymentCancelRequest.java | 4 + .../payment/dto/request/PaymentRequest.java | 4 + .../dto/response/PaymentCancelResponse.java | 14 + .../PaymentCancelResponseDeserializer.java | 36 + .../payment/dto/response/PaymentResponse.java | 11 + .../response/ReservationPaymentResponse.java | 15 + .../response/TossPaymentErrorResponse.java | 4 + .../payment/service/PaymentService.java | 82 +++ .../controller/ReservationController.java | 273 ++++++++ .../controller/ReservationTimeController.java | 112 ++++ .../reservation/domain/Reservation.java | 127 ++++ .../reservation/domain/ReservationStatus.java | 13 + .../reservation/domain/ReservationTime.java | 59 ++ .../repository/ReservationRepository.java | 62 ++ .../ReservationSearchSpecification.java | 87 +++ .../repository/ReservationTimeRepository.java | 13 + .../dto/request/AdminReservationRequest.java | 18 + .../dto/request/ReservationRequest.java | 36 + .../dto/request/ReservationTimeRequest.java | 31 + .../dto/request/WaitingRequest.java | 20 + .../dto/response/MyReservationResponse.java | 33 + .../dto/response/MyReservationsResponse.java | 11 + .../dto/response/ReservationResponse.java | 42 ++ .../response/ReservationTimeInfoResponse.java | 16 + .../ReservationTimeInfosResponse.java | 11 + .../dto/response/ReservationTimeResponse.java | 19 + .../response/ReservationTimesResponse.java | 11 + .../dto/response/ReservationsResponse.java | 11 + .../service/ReservationService.java | 227 +++++++ .../service/ReservationTimeService.java | 100 +++ .../ReservationWithPaymentService.java | 56 ++ .../system/auth/annotation/Admin.java | 11 + .../system/auth/annotation/LoginRequired.java | 11 + .../system/auth/annotation/MemberId.java | 11 + .../auth/controller/AuthController.java | 102 +++ .../system/auth/dto/LoginCheckResponse.java | 9 + .../system/auth/dto/LoginRequest.java | 17 + .../auth/interceptor/AdminInterceptor.java | 86 +++ .../auth/interceptor/LoginInterceptor.java | 79 +++ .../system/auth/jwt/JwtHandler.java | 65 ++ .../system/auth/jwt/dto/TokenDto.java | 4 + .../auth/resolver/MemberIdResolver.java | 53 ++ .../system/auth/service/AuthService.java | 34 + .../system/config/JacksonConfig.java | 40 ++ .../system/config/SwaggerConfig.java | 76 +++ .../system/config/WebMvcConfig.java | 38 ++ .../system/dto/response/ErrorResponse.java | 15 + .../dto/response/RoomEscapeApiResponse.java | 20 + .../system/exception/ErrorType.java | 60 ++ .../exception/ExceptionControllerAdvice.java | 71 ++ .../system/exception/RoomEscapeException.java | 41 ++ .../theme/controller/ThemeController.java | 101 +++ .../java/roomescape/theme/domain/Theme.java | 65 ++ .../domain/repository/ThemeRepository.java | 34 + .../roomescape/theme/dto/ThemeRequest.java | 21 + .../roomescape/theme/dto/ThemeResponse.java | 21 + .../roomescape/theme/dto/ThemesResponse.java | 11 + .../theme/service/ThemeService.java | 84 +++ .../view/controller/AdminPageController.java | 40 ++ .../view/controller/AuthPageController.java | 13 + .../view/controller/ClientPageController.java | 27 + src/main/resources/application.yaml | 37 ++ src/main/resources/data.sql | 68 ++ src/main/resources/static/css/reservation.css | 15 + src/main/resources/static/css/style.css | 62 ++ src/main/resources/static/css/toss-style.css | 132 ++++ src/main/resources/static/favicon.ico | Bin 0 -> 1492 bytes .../resources/static/image/admin-logo.png | Bin 0 -> 4640 bytes .../static/image/default-profile.png | Bin 0 -> 20300 bytes src/main/resources/static/js/ranking.js | 45 ++ .../resources/static/js/reservation-mine.js | 57 ++ .../resources/static/js/reservation-new.js | 194 ++++++ .../static/js/reservation-with-member.js | 250 +++++++ src/main/resources/static/js/reservation.js | 179 +++++ src/main/resources/static/js/scripts.js | 0 src/main/resources/static/js/theme.js | 136 ++++ src/main/resources/static/js/time.js | 135 ++++ .../resources/static/js/user-reservation.js | 273 ++++++++ src/main/resources/static/js/user-scripts.js | 152 +++++ src/main/resources/static/js/waiting.js | 69 ++ src/main/resources/templates/admin/index.html | 61 ++ .../templates/admin/reservation-new.html | 111 ++++ .../templates/admin/reservation.html | 63 ++ src/main/resources/templates/admin/theme.html | 80 +++ src/main/resources/templates/admin/time.html | 78 +++ .../resources/templates/admin/waiting.html | 77 +++ src/main/resources/templates/index.html | 56 ++ src/main/resources/templates/login.html | 64 ++ .../resources/templates/reservation-mine.html | 70 ++ src/main/resources/templates/reservation.html | 103 +++ src/main/resources/templates/signup.html | 67 ++ .../global/auth/jwt/JwtHandlerTest.java | 118 ++++ .../controller/MemberControllerTest.java | 72 ++ .../roomescape/member/domain/MemberTest.java | 26 + .../client/SampleTossPaymentConst.java | 178 +++++ .../payment/client/TossPaymentClientTest.java | 120 ++++ .../payment/domain/CanceledPaymentTest.java | 22 + .../payment/domain/PaymentTest.java | 78 +++ ...PaymentCancelResponseDeserializerTest.java | 52 ++ .../payment/service/PaymentServiceTest.java | 141 ++++ .../controller/ReservationControllerTest.java | 620 ++++++++++++++++++ .../ReservationTimeControllerTest.java | 247 +++++++ .../reservation/domain/ReservationTest.java | 55 ++ .../domain/ReservationTimeTest.java | 19 + .../ReservationSearchSpecificationTest.java | 175 +++++ .../service/ReservationServiceTest.java | 219 +++++++ .../service/ReservationTimeServiceTest.java | 88 +++ .../ReservationWithPaymentServiceTest.java | 157 +++++ .../auth/controller/AuthControllerTest.java | 118 ++++ .../system/auth/service/AuthServiceTest.java | 65 ++ .../system/config/JacksonConfigTest.java | 78 +++ .../theme/controller/ThemeControllerTest.java | 184 ++++++ .../theme/service/ThemeServiceTest.java | 164 +++++ .../controller/AdminPageControllerTest.java | 219 +++++++ .../controller/AuthPageControllerTest.java | 25 + .../controller/ClientPageControllerTest.java | 102 +++ src/test/resources/application.yaml | 27 + src/test/resources/reservationData.sql | 194 ++++++ src/test/resources/test_search_data.sql | 41 ++ src/test/resources/truncate.sql | 25 + 142 files changed, 10102 insertions(+), 45 deletions(-) create mode 100644 LICENSE create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 src/main/java/roomescape/RoomescapeApplication.java create mode 100644 src/main/java/roomescape/member/controller/MemberController.java create mode 100644 src/main/java/roomescape/member/domain/Member.java create mode 100644 src/main/java/roomescape/member/domain/Role.java create mode 100644 src/main/java/roomescape/member/domain/repository/MemberRepository.java create mode 100644 src/main/java/roomescape/member/dto/MemberResponse.java create mode 100644 src/main/java/roomescape/member/dto/MembersResponse.java create mode 100644 src/main/java/roomescape/member/service/MemberService.java create mode 100644 src/main/java/roomescape/payment/PaymentConfig.java create mode 100644 src/main/java/roomescape/payment/client/PaymentProperties.java create mode 100644 src/main/java/roomescape/payment/client/TossPaymentClient.java create mode 100644 src/main/java/roomescape/payment/domain/CanceledPayment.java create mode 100644 src/main/java/roomescape/payment/domain/Payment.java create mode 100644 src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java create mode 100644 src/main/java/roomescape/payment/domain/repository/PaymentRepository.java create mode 100644 src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java create mode 100644 src/main/java/roomescape/payment/dto/request/PaymentRequest.java create mode 100644 src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java create mode 100644 src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java create mode 100644 src/main/java/roomescape/payment/dto/response/PaymentResponse.java create mode 100644 src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java create mode 100644 src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java create mode 100644 src/main/java/roomescape/payment/service/PaymentService.java create mode 100644 src/main/java/roomescape/reservation/controller/ReservationController.java create mode 100644 src/main/java/roomescape/reservation/controller/ReservationTimeController.java create mode 100644 src/main/java/roomescape/reservation/domain/Reservation.java create mode 100644 src/main/java/roomescape/reservation/domain/ReservationStatus.java create mode 100644 src/main/java/roomescape/reservation/domain/ReservationTime.java create mode 100644 src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java create mode 100644 src/main/java/roomescape/reservation/domain/repository/ReservationSearchSpecification.java create mode 100644 src/main/java/roomescape/reservation/domain/repository/ReservationTimeRepository.java create mode 100644 src/main/java/roomescape/reservation/dto/request/AdminReservationRequest.java create mode 100644 src/main/java/roomescape/reservation/dto/request/ReservationRequest.java create mode 100644 src/main/java/roomescape/reservation/dto/request/ReservationTimeRequest.java create mode 100644 src/main/java/roomescape/reservation/dto/request/WaitingRequest.java create mode 100644 src/main/java/roomescape/reservation/dto/response/MyReservationResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/MyReservationsResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/ReservationResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/ReservationTimeInfoResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/ReservationTimeInfosResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/ReservationTimeResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/ReservationTimesResponse.java create mode 100644 src/main/java/roomescape/reservation/dto/response/ReservationsResponse.java create mode 100644 src/main/java/roomescape/reservation/service/ReservationService.java create mode 100644 src/main/java/roomescape/reservation/service/ReservationTimeService.java create mode 100644 src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java create mode 100644 src/main/java/roomescape/system/auth/annotation/Admin.java create mode 100644 src/main/java/roomescape/system/auth/annotation/LoginRequired.java create mode 100644 src/main/java/roomescape/system/auth/annotation/MemberId.java create mode 100644 src/main/java/roomescape/system/auth/controller/AuthController.java create mode 100644 src/main/java/roomescape/system/auth/dto/LoginCheckResponse.java create mode 100644 src/main/java/roomescape/system/auth/dto/LoginRequest.java create mode 100644 src/main/java/roomescape/system/auth/interceptor/AdminInterceptor.java create mode 100644 src/main/java/roomescape/system/auth/interceptor/LoginInterceptor.java create mode 100644 src/main/java/roomescape/system/auth/jwt/JwtHandler.java create mode 100644 src/main/java/roomescape/system/auth/jwt/dto/TokenDto.java create mode 100644 src/main/java/roomescape/system/auth/resolver/MemberIdResolver.java create mode 100644 src/main/java/roomescape/system/auth/service/AuthService.java create mode 100644 src/main/java/roomescape/system/config/JacksonConfig.java create mode 100644 src/main/java/roomescape/system/config/SwaggerConfig.java create mode 100644 src/main/java/roomescape/system/config/WebMvcConfig.java create mode 100644 src/main/java/roomescape/system/dto/response/ErrorResponse.java create mode 100644 src/main/java/roomescape/system/dto/response/RoomEscapeApiResponse.java create mode 100644 src/main/java/roomescape/system/exception/ErrorType.java create mode 100644 src/main/java/roomescape/system/exception/ExceptionControllerAdvice.java create mode 100644 src/main/java/roomescape/system/exception/RoomEscapeException.java create mode 100644 src/main/java/roomescape/theme/controller/ThemeController.java create mode 100644 src/main/java/roomescape/theme/domain/Theme.java create mode 100644 src/main/java/roomescape/theme/domain/repository/ThemeRepository.java create mode 100644 src/main/java/roomescape/theme/dto/ThemeRequest.java create mode 100644 src/main/java/roomescape/theme/dto/ThemeResponse.java create mode 100644 src/main/java/roomescape/theme/dto/ThemesResponse.java create mode 100644 src/main/java/roomescape/theme/service/ThemeService.java create mode 100644 src/main/java/roomescape/view/controller/AdminPageController.java create mode 100644 src/main/java/roomescape/view/controller/AuthPageController.java create mode 100644 src/main/java/roomescape/view/controller/ClientPageController.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/data.sql create mode 100644 src/main/resources/static/css/reservation.css create mode 100644 src/main/resources/static/css/style.css create mode 100644 src/main/resources/static/css/toss-style.css create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/image/admin-logo.png create mode 100644 src/main/resources/static/image/default-profile.png create mode 100644 src/main/resources/static/js/ranking.js create mode 100644 src/main/resources/static/js/reservation-mine.js create mode 100644 src/main/resources/static/js/reservation-new.js create mode 100644 src/main/resources/static/js/reservation-with-member.js create mode 100644 src/main/resources/static/js/reservation.js create mode 100644 src/main/resources/static/js/scripts.js create mode 100644 src/main/resources/static/js/theme.js create mode 100644 src/main/resources/static/js/time.js create mode 100644 src/main/resources/static/js/user-reservation.js create mode 100644 src/main/resources/static/js/user-scripts.js create mode 100644 src/main/resources/static/js/waiting.js create mode 100644 src/main/resources/templates/admin/index.html create mode 100644 src/main/resources/templates/admin/reservation-new.html create mode 100644 src/main/resources/templates/admin/reservation.html create mode 100644 src/main/resources/templates/admin/theme.html create mode 100644 src/main/resources/templates/admin/time.html create mode 100644 src/main/resources/templates/admin/waiting.html create mode 100644 src/main/resources/templates/index.html create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/reservation-mine.html create mode 100644 src/main/resources/templates/reservation.html create mode 100644 src/main/resources/templates/signup.html create mode 100644 src/test/java/roomescape/global/auth/jwt/JwtHandlerTest.java create mode 100644 src/test/java/roomescape/member/controller/MemberControllerTest.java create mode 100644 src/test/java/roomescape/member/domain/MemberTest.java create mode 100644 src/test/java/roomescape/payment/client/SampleTossPaymentConst.java create mode 100644 src/test/java/roomescape/payment/client/TossPaymentClientTest.java create mode 100644 src/test/java/roomescape/payment/domain/CanceledPaymentTest.java create mode 100644 src/test/java/roomescape/payment/domain/PaymentTest.java create mode 100644 src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java create mode 100644 src/test/java/roomescape/payment/service/PaymentServiceTest.java create mode 100644 src/test/java/roomescape/reservation/controller/ReservationControllerTest.java create mode 100644 src/test/java/roomescape/reservation/controller/ReservationTimeControllerTest.java create mode 100644 src/test/java/roomescape/reservation/domain/ReservationTest.java create mode 100644 src/test/java/roomescape/reservation/domain/ReservationTimeTest.java create mode 100644 src/test/java/roomescape/reservation/domain/repository/ReservationSearchSpecificationTest.java create mode 100644 src/test/java/roomescape/reservation/service/ReservationServiceTest.java create mode 100644 src/test/java/roomescape/reservation/service/ReservationTimeServiceTest.java create mode 100644 src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java create mode 100644 src/test/java/roomescape/system/auth/controller/AuthControllerTest.java create mode 100644 src/test/java/roomescape/system/auth/service/AuthServiceTest.java create mode 100644 src/test/java/roomescape/system/config/JacksonConfigTest.java create mode 100644 src/test/java/roomescape/theme/controller/ThemeControllerTest.java create mode 100644 src/test/java/roomescape/theme/service/ThemeServiceTest.java create mode 100644 src/test/java/roomescape/view/controller/AdminPageControllerTest.java create mode 100644 src/test/java/roomescape/view/controller/AuthPageControllerTest.java create mode 100644 src/test/java/roomescape/view/controller/ClientPageControllerTest.java create mode 100644 src/test/resources/application.yaml create mode 100644 src/test/resources/reservationData.sql create mode 100644 src/test/resources/test_search_data.sql create mode 100644 src/test/resources/truncate.sql 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 0000000000000000000000000000000000000000..41d9927a4d4fb3f96a785543079b8df6723c946b GIT binary patch literal 59821 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..79a18a8f24b373b82ff960cabd2bbe70948709d0 GIT binary patch literal 1492 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 W@S<$gOrt{p00007|Op)OC{L7}0c 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 literal 0 HcmV?d00001 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;