diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..49eb7679 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,47 @@ +name: ssh for release deploy + +on: + push: + branches: [ develop ] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: SSH Remote Commands + uses: appleboy/ssh-action@v0.1.4 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + port: ${{ secrets.PORT }} + timeout: 40s + + script: | + echo "1. project root로 이동" + cd ~/livable/server + + echo "2. git pull" + git pull origin develop + + echo "3. project build" + ./gradlew clean build + + if [ $? -eq 0 ]; then + # 빌드 성공 시 + echo "4. 실행중인 프로세스 확인" + CURRENT_PID=$(pgrep -f ${{ secrets.PROJECT_ROOT }}/build/libs/server-0.0.1-SNAPSHOT.jar) + + echo "5. 실행중인 프로세스 중지" + sudo kill -9 $CURRENT_PID + + echo "6. 홈 경로 이동 후 프로젝트 재실행" + cd ~ + nohup java -jar ${{ secrets.PROJECT_ROOT }}/build/libs/server-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > nohup.out 2> nohup.err < /dev/null & + else + echo "프로젝트 Build 실패" + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..89fc3b1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### jacoco ### +jacoco/ + +### 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/ +application-dev.yml +application-prod.yml +/src/test/resources/test.properties + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/README.md b/README.md index 59069fb5..1532fb24 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,182 @@ -# server -오피스 혁신을 위한 통합 플랫폼 ‘오피스너’의 유저 가입자수와 WAU를 높이기 위한 프로젝트 +image + + +

🏢 오피스 혁신을 위한 통합 플랫폼, 오피스너

+ +> **개발 기간** : 2023.09.11(월) ~ 2023.10.06(목)
+> **배포 주소** : [오피스너](https://officener.vercel.app)
+> **백엔드 레포지토리** : [백엔드](https://github.com/livable-final/server)
+> **프론트 유저 레포지토리** : [프론트](https://github.com/livable-final/client)
+ + +



+ +

프로젝트 목적

+ +- 기존 오피스너 서비스는 관리자, 관리 멤버 이외의 일반 유저의 가입과 이용 동기가 부족 +- 이용자가 매일 사용해야 할 만한 컨텐츠와 기능의 부재 +- "유저 가입자 수"와 "WAU" 상승을 목적으로 시작된 기업 연계 프로젝트 + +



+ +

사용한 기술스택

+ +

+ + + + + +

+ +

+ + + +

+ +

+ + + +

+ +

+ + + + + +

+ + +



+ + +

백엔드 아키텍처

+ +![image](https://github.com/khsrla9806/livable-server/assets/70641477/383dee4a-7032-4ef0-b147-d315a4bb5672) + + +



+ + +

ERD

+ +![image](https://github.com/khsrla9806/livable-server/assets/70641477/4ae505f2-139f-406c-b358-b1f11d1982f6) + + +



+ + +

Jacoco 테스트 커버리지

+ +> 백엔드팀 테스트 커버리지 목표 40% 이상 달성 + +![image](https://github.com/khsrla9806/livable-server/assets/70641477/cf7ea67a-e881-4a0a-a26c-4db876acb0ea) + + + +



+ + +

프로젝트 실행하기

+ +### application.yml +``` yaml +# Spring, DB propertiesg setting +spring: + datasource: + url: #DB Address + driver-class-name: #DB Driver + username: #DB Username + password: #DB Password + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + default_batch_fetch_size: 100 + + quartz: + auto-startup: true + job-store-type: jdbc + jdbc: + initialize-schema: never + overwrite-existing-jobs: false + +# S3 propertiesg setting +cloud: + aws: + s3: + bucket: #S3 bucketName + credentials: + accessKey: #S3 accessKey + secretKey: #S3 secretKey + region: + static: ap-northeast-2 + stack: + auto: false + +# JWT properties setting +jwt: + secret: #JWT key + +# +``` + +### build and test +```bash +$ ./gradlew clean build +``` + +### run +```bash +$ java -jar ./build/libs/server-0.0.1-SNAPSHOT.jar +``` + + +



+ + +

백엔드 팀원

+ + + + + + + + + + +
+
정현수

+
+
김훈섭

+
+
최태윤

+
+
김태일

+
+
배종윤

+
+ +



+ +

팀원 역할

+ +
+ + | 이름 | 역할 | + | :-----------------: | -------------------------------- | + | 정현수
`팀장` | - 데이터베이스 설계
- API 명세서 설계
- S3 업로드 환경 구성
- 포인트 API 구현
- 식당 리뷰 API 구현
- 다중 이미지 처리 구현
| + | 김훈섭 | - 데이터베이스 설계
- API 명세서 설계
- EC2 서버환경 구성
- 자동화 배포환경 구성
- SSL 적용 (HTTPS)
- 초대장 CRUD 구현
- Kakao 알림톡 적용
| + | 최태윤 | - 데이터베이스 설계
- API 명세서 설계
- 자동화 배포환경 구성
- 방문증 API 구현
| + | 김태일 | - 데이터 베이스 설계
- API 명세서 설계
- 식당 리뷰 API 구현
| + | 배종윤 | - 데이터 베이스 설계
- API 명세서 설계
- 홈 화면 API 구현
- 오늘 점심 뭐먹지 룰렛 API 구현
- Spring Quartz 스케쥴러를 이용한 집계 기능 구현
| + +
+ + diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..4af6188e --- /dev/null +++ b/build.gradle @@ -0,0 +1,155 @@ +buildscript { + ext { + queryDslVersion = "5.0.0" + } +} + +plugins { + id 'java' + id 'jacoco' //jacoco + id 'org.springframework.boot' version '2.7.15' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id 'org.asciidoctor.jvm.convert' version '3.3.2' + id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" +} + +jacoco { + toolVersion = "0.8.10" +} + +/*jacoco setting start*/ +jacocoTestReport { + dependsOn test + reports { + html.required = true + xml.required = true + } + + def Qdomains = [] + for (qPattern in "**/QA".."**/QZ") { + Qdomains.add(qPattern + "*") + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, + exclude: [] + Qdomains + ) + })) + } + finalizedBy jacocoTestCoverageVerification +} + +jacocoTestCoverageVerification { + def Qdomains = [] + for (qPattern in "*.QA".."*.QZ") { + Qdomains.add(qPattern + "*") + } + violationRules { + rule { + limit { + minimum = 0.30 + } + } + + rule { + enabled = true + + + limit { + counter = 'METHOD' + value = 'COVEREDRATIO' + minimum = 0.50 + } + + excludes = [] + Qdomains + } + } +} +/*jacoco setting end*/ + +group = 'com.livable' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '11' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + set('snippetsDir', file("build/generated-snippets")) +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}" + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'org.mockito:mockito-inline:3.6.0' + implementation 'com.google.zxing:core:3.5.2' + implementation 'com.google.zxing:javase:3.5.2' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' +// implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' + implementation 'org.springframework.boot:spring-boot-starter-quartz' + + implementation "io.springfox:springfox-boot-starter:3.0.0" + implementation "io.springfox:springfox-swagger-ui:3.0.0" +} + +test { + systemProperty 'user.timezone', 'Asia/Seoul' +} + +tasks.named('test') { + outputs.dir snippetsDir + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +tasks.named('asciidoctor') { + inputs.dir snippetsDir + dependsOn test +} + +/* QueryDSL setting start */ +def querydslDir = "$buildDir/generated/querydsl" + +querydsl { + jpa = true + querydslSourcesDir = querydslDir +} + +sourceSets { + main.java.srcDir querydslDir +} + +configurations { + complieOnly { + extendsFrom annotationProcessor + } + querydsl.extendsFrom compileClasspath +} + +compileQuerydsl { + options.annotationProcessorPath = configurations.querydsl +} +/* QueryDSL setting end */ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..033e24c4 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9f4197d5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..fcb6fca1 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, 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 \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..6689b85b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +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% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lombok.config b/lombok.config new file mode 100644 index 00000000..8f7e8aa1 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..096502d2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'server' diff --git a/src/main/java/com/livable/server/LivableServerApplication.java b/src/main/java/com/livable/server/LivableServerApplication.java new file mode 100644 index 00000000..487c7721 --- /dev/null +++ b/src/main/java/com/livable/server/LivableServerApplication.java @@ -0,0 +1,25 @@ +package com.livable.server; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import javax.annotation.PostConstruct; +import java.time.LocalDateTime; +import java.util.TimeZone; + +@Slf4j +@SpringBootApplication +public class LivableServerApplication { + + public static void main(String[] args) { + SpringApplication.run(LivableServerApplication.class, args); + } + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + log.info("프로젝트 세팅 시간: {}", LocalDateTime.now()); // TODO: 서버 로그 테스트 후 지울예정 + } + +} diff --git a/src/main/java/com/livable/server/admin/controller/AdminController.java b/src/main/java/com/livable/server/admin/controller/AdminController.java new file mode 100644 index 00000000..0eed85f5 --- /dev/null +++ b/src/main/java/com/livable/server/admin/controller/AdminController.java @@ -0,0 +1,39 @@ +package com.livable.server.admin.controller; + +import com.livable.server.admin.domain.VisitationQuery; +import com.livable.server.admin.service.AdminService; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/admin") +public class AdminController { + + private final AdminService adminService; + + /** + * http://localhost:8080/api/admin/visitation?page=10&size=1&queryCondition=COMPANY&query=sixsense&startDate=2023-09-24&endDate=2023-09-25 + * @param pageable + * @param visitationQueryCondition + */ + + @GetMapping("/visitation") + public ResponseEntity getVisitationList( + Pageable pageable, VisitationQuery visitationQueryCondition, @LoginActor Actor actor + ) { + + JwtTokenProvider.checkAdminToken(actor); + + Long adminId = actor.getId(); + + return adminService.getVisitationList(pageable, visitationQueryCondition, adminId); + } +} diff --git a/src/main/java/com/livable/server/admin/domain/AdminErrorCode.java b/src/main/java/com/livable/server/admin/domain/AdminErrorCode.java new file mode 100644 index 00000000..09599d2a --- /dev/null +++ b/src/main/java/com/livable/server/admin/domain/AdminErrorCode.java @@ -0,0 +1,17 @@ +package com.livable.server.admin.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum AdminErrorCode implements ErrorCode { + NOT_EXIST_ADMIN(HttpStatus.BAD_REQUEST, "존재하지 않는 관리자 입니다."), + INVALID_QUERY(HttpStatus.BAD_REQUEST, "검색 조건이 올바르지 않습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/livable/server/admin/domain/VisitationQuery.java b/src/main/java/com/livable/server/admin/domain/VisitationQuery.java new file mode 100644 index 00000000..1bfe8c46 --- /dev/null +++ b/src/main/java/com/livable/server/admin/domain/VisitationQuery.java @@ -0,0 +1,43 @@ +package com.livable.server.admin.domain; + +import com.livable.server.core.exception.GlobalRuntimeException; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDate; + +@Getter +@NoArgsConstructor +@Setter +@ToString +public class VisitationQuery { + + private VisitationQueryCondition queryCondition; + private String query; + private LocalDate startDate; + private LocalDate endDate; + + public void setDefaultDate() { + this.startDate = LocalDate.now(); + this.endDate = LocalDate.now(); + } + + public void validate() { + if (startDate == null && endDate == null) { + setDefaultDate(); + return; + } + + if (startDate != null && endDate != null) { + checkDateTime(startDate, endDate); + } + } + + private void checkDateTime(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate) || endDate.isBefore(startDate)) { + throw new GlobalRuntimeException(AdminErrorCode.INVALID_QUERY); + } + } +} diff --git a/src/main/java/com/livable/server/admin/domain/VisitationQueryCondition.java b/src/main/java/com/livable/server/admin/domain/VisitationQueryCondition.java new file mode 100644 index 00000000..77385457 --- /dev/null +++ b/src/main/java/com/livable/server/admin/domain/VisitationQueryCondition.java @@ -0,0 +1,11 @@ +package com.livable.server.admin.domain; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum VisitationQueryCondition { + COMPANY, VISITOR; +} diff --git a/src/main/java/com/livable/server/admin/dto/AdminResponse.java b/src/main/java/com/livable/server/admin/dto/AdminResponse.java new file mode 100644 index 00000000..b63dd8c8 --- /dev/null +++ b/src/main/java/com/livable/server/admin/dto/AdminResponse.java @@ -0,0 +1,73 @@ +package com.livable.server.admin.dto; + +import lombok.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AdminResponse { + + @Getter + @Builder + public static class ListDTO { + private Long invitationId; + private String company; + private String host; + private Long visitorId; + private LocalDateTime startDateTime; + private LocalDateTime visitTime; + private String visitorName; + private String officeName; + private String carNumber; + private LocalDateTime inTime; + private LocalDateTime outTime; + private Integer stayTime; + } + + @Getter + @AllArgsConstructor + public static class ProjectionForListDTO { + private Long invitationId; + private String company; + private String host; + private Long visitorId; + private LocalDate startDate; + private LocalTime startTime; + private LocalDateTime visitTime; + private String visitorName; + private String officeName; + private String carNumber; + private LocalDateTime inTime; + private LocalDateTime outTime; + + public ListDTO toListDTO() { + return ListDTO.builder() + .invitationId(invitationId) + .company(company) + .host(host) + .visitorId(visitorId) + .startDateTime(LocalDateTime.of(startDate, startTime)) + .visitTime(visitTime) + .visitorName(visitorName) + .officeName(officeName) + .carNumber(carNumber) + .inTime(inTime) + .outTime(outTime) + .stayTime(calculateStayTime(inTime, outTime)) + .build(); + } + + private Integer calculateStayTime(LocalDateTime inTime, LocalDateTime outTime) { + if (inTime != null && outTime != null) { + Duration duration = Duration.between(inTime, outTime); + + return Long.valueOf(duration.getSeconds()).intValue() / 60; + } + + return null; + } + } +} diff --git a/src/main/java/com/livable/server/admin/repository/AdminQueryRepository.java b/src/main/java/com/livable/server/admin/repository/AdminQueryRepository.java new file mode 100644 index 00000000..630b4ed1 --- /dev/null +++ b/src/main/java/com/livable/server/admin/repository/AdminQueryRepository.java @@ -0,0 +1,13 @@ +package com.livable.server.admin.repository; + +import com.livable.server.admin.domain.VisitationQuery; +import com.livable.server.admin.dto.AdminResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AdminQueryRepository { + + Page findVisitationWithQuery( + Pageable pageable, VisitationQuery visitationQuery, Long buildingId + ); +} diff --git a/src/main/java/com/livable/server/admin/repository/AdminQueryRepositoryImpl.java b/src/main/java/com/livable/server/admin/repository/AdminQueryRepositoryImpl.java new file mode 100644 index 00000000..5f677287 --- /dev/null +++ b/src/main/java/com/livable/server/admin/repository/AdminQueryRepositoryImpl.java @@ -0,0 +1,98 @@ +package com.livable.server.admin.repository; + + +import com.livable.server.admin.domain.VisitationQuery; +import com.livable.server.admin.domain.VisitationQueryCondition; +import com.livable.server.admin.dto.AdminResponse; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.stream.Collectors; + +import static com.livable.server.entity.QCompany.company; +import static com.livable.server.entity.QParkingLog.parkingLog; +import static com.livable.server.entity.QVisitor.visitor; +import static com.livable.server.entity.QInvitation.invitation; +import static com.livable.server.entity.QMember.member; +import static com.livable.server.entity.QBuilding.building; + +@RequiredArgsConstructor +public class AdminQueryRepositoryImpl implements AdminQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findVisitationWithQuery( + Pageable pageable, VisitationQuery visitationQuery, Long buildingId + ) { + + BooleanBuilder dateTimeBuilder = new BooleanBuilder(); + + if (visitationQuery.getStartDate() != null) { + dateTimeBuilder.and(invitation.startDate.goe(visitationQuery.getStartDate())); + } + + if (visitationQuery.getEndDate() != null) { + dateTimeBuilder.and(invitation.startDate.loe(visitationQuery.getEndDate())); + } + + BooleanBuilder queryBuilder = new BooleanBuilder(); + + if (visitationQuery.getQueryCondition() != null + && visitationQuery.getQueryCondition().equals(VisitationQueryCondition.VISITOR) + && StringUtils.hasText(visitationQuery.getQuery())) { + queryBuilder.and(visitor.name.contains(visitationQuery.getQuery())); + } + + if (visitationQuery.getQueryCondition() != null + && visitationQuery.getQueryCondition().equals(VisitationQueryCondition.COMPANY) + && StringUtils.hasText(visitationQuery.getQuery())) { + queryBuilder.and(company.name.contains(visitationQuery.getQuery())); + } + + JPAQuery query = + queryFactory.select(Projections.constructor(AdminResponse.ProjectionForListDTO.class, + invitation.id, + company.name, + member.name, + visitor.id, + invitation.startDate, + invitation.startTime, + visitor.firstVisitedTime, + visitor.name, + invitation.officeName, + parkingLog.carNumber, + parkingLog.inTime, + parkingLog.outTime + )) + .from(visitor) + .leftJoin(parkingLog).on(parkingLog.visitor.id.eq(visitor.id)) + .join(invitation).on(dateTimeBuilder.and(visitor.invitation.id.eq(invitation.id))) + .join(member).on(invitation.member.id.eq(member.id)) + .join(company).on(member.company.id.eq(company.id)) + .join(building).on(company.building.id.eq(building.id), building.id.eq(buildingId)) + .where(queryBuilder) + .orderBy(invitation.startDate.asc()); + + List projection = query.offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + List content = projection.stream() + .map(AdminResponse.ProjectionForListDTO::toListDTO) + .collect(Collectors.toList()); + + long count = query.fetchCount(); + + + return new PageImpl<>(content, pageable, count); + } +} diff --git a/src/main/java/com/livable/server/admin/repository/AdminRepository.java b/src/main/java/com/livable/server/admin/repository/AdminRepository.java new file mode 100644 index 00000000..c6cf0b3b --- /dev/null +++ b/src/main/java/com/livable/server/admin/repository/AdminRepository.java @@ -0,0 +1,8 @@ +package com.livable.server.admin.repository; + +import com.livable.server.entity.Admin; +import org.springframework.data.jpa.repository.JpaRepository; + + +public interface AdminRepository extends JpaRepository, AdminQueryRepository { +} diff --git a/src/main/java/com/livable/server/admin/service/AdminService.java b/src/main/java/com/livable/server/admin/service/AdminService.java new file mode 100644 index 00000000..fe391850 --- /dev/null +++ b/src/main/java/com/livable/server/admin/service/AdminService.java @@ -0,0 +1,41 @@ +package com.livable.server.admin.service; + +import com.livable.server.admin.domain.AdminErrorCode; +import com.livable.server.admin.domain.VisitationQuery; +import com.livable.server.admin.dto.AdminResponse; +import com.livable.server.admin.repository.AdminRepository; +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.response.ApiResponse; +import com.livable.server.entity.Admin; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class AdminService { + + private final AdminRepository adminRepository; + + @Transactional(readOnly = true) + public ResponseEntity getVisitationList( + Pageable pageable, VisitationQuery visitationQuery, Long adminId + ) { + visitationQuery.validate(); + + Optional optionalAdmin = adminRepository.findById(adminId); + Admin admin = optionalAdmin.orElseThrow( + () -> new GlobalRuntimeException(AdminErrorCode.NOT_EXIST_ADMIN)); + + Page responseBody + = adminRepository.findVisitationWithQuery(pageable, visitationQuery, admin.getBuilding().getId()); + + return ApiResponse.success(responseBody, HttpStatus.OK); + } +} diff --git a/src/main/java/com/livable/server/core/config/JpaConfig.java b/src/main/java/com/livable/server/core/config/JpaConfig.java new file mode 100644 index 00000000..8bd76345 --- /dev/null +++ b/src/main/java/com/livable/server/core/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.livable.server.core.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/com/livable/server/core/config/QueryDslConfig.java b/src/main/java/com/livable/server/core/config/QueryDslConfig.java new file mode 100644 index 00000000..5f5b0518 --- /dev/null +++ b/src/main/java/com/livable/server/core/config/QueryDslConfig.java @@ -0,0 +1,16 @@ +package com.livable.server.core.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.persistence.EntityManager; + +@Configuration +public class QueryDslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager em) { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/com/livable/server/core/config/S3Config.java b/src/main/java/com/livable/server/core/config/S3Config.java new file mode 100644 index 00000000..2a633de0 --- /dev/null +++ b/src/main/java/com/livable/server/core/config/S3Config.java @@ -0,0 +1,34 @@ +package com.livable.server.core.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/livable/server/core/config/SwaggerConfig.java b/src/main/java/com/livable/server/core/config/SwaggerConfig.java new file mode 100644 index 00000000..70ecd9fd --- /dev/null +++ b/src/main/java/com/livable/server/core/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.livable.server.core.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.builders.RequestHandlerSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spring.web.plugins.Docket; + +@Configuration +public class SwaggerConfig { + + private static final String SERVICE_NAME = "오피스너 - 식스센스"; + private static final String API_VERSION = "V1"; + private static final String API_DESCRIPTION = "오피스너"; + private static final String API_URL = "https://livableserver.site/"; + + @Bean + public Docket api() { + return new Docket(DocumentationType.OAS_30) + .apiInfo(apiInfo()) + .select() + .apis(RequestHandlerSelectors.basePackage("com.livable.server")) + .paths(PathSelectors.any()) + .build(); + } + + private ApiInfo apiInfo() { + return new ApiInfoBuilder().title(SERVICE_NAME) + .version(API_VERSION) + .description(API_DESCRIPTION) + .termsOfServiceUrl(API_URL) + .build(); + } +} diff --git a/src/main/java/com/livable/server/core/config/WebConfig.java b/src/main/java/com/livable/server/core/config/WebConfig.java new file mode 100644 index 00000000..1c84f0ee --- /dev/null +++ b/src/main/java/com/livable/server/core/config/WebConfig.java @@ -0,0 +1,43 @@ +package com.livable.server.core.config; + +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActorArgumentResolver; +import com.livable.server.core.util.StringToLocalDateConverter; +import com.livable.server.core.util.StringToRestaurantCategoryConverter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final JwtTokenProvider tokenProvider; + + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new StringToRestaurantCategoryConverter()); + registry.addConverter(new StringToLocalDateConverter()); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedHeaders("*") + .allowedMethods("*") + .allowCredentials(true) + .exposedHeaders("Authorization") + .allowedOriginPatterns("*"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginActorArgumentResolver(tokenProvider)); + } +} diff --git a/src/main/java/com/livable/server/core/exception/ErrorCode.java b/src/main/java/com/livable/server/core/exception/ErrorCode.java new file mode 100644 index 00000000..cadd9c54 --- /dev/null +++ b/src/main/java/com/livable/server/core/exception/ErrorCode.java @@ -0,0 +1,10 @@ +package com.livable.server.core.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + String getMessage(); + + HttpStatus getHttpStatus(); +} diff --git a/src/main/java/com/livable/server/core/exception/GlobalErrorCode.java b/src/main/java/com/livable/server/core/exception/GlobalErrorCode.java new file mode 100644 index 00000000..8cdfe15b --- /dev/null +++ b/src/main/java/com/livable/server/core/exception/GlobalErrorCode.java @@ -0,0 +1,15 @@ +package com.livable.server.core.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements ErrorCode { + + INVALID_TYPE(HttpStatus.BAD_REQUEST, "입력 형식이 올바르지 않습니다"); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/livable/server/core/exception/GlobalExceptionHandler.java b/src/main/java/com/livable/server/core/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..455ba006 --- /dev/null +++ b/src/main/java/com/livable/server/core/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package com.livable.server.core.exception; + +import com.livable.server.core.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity methodMethodArgumentTypeMismatchExceptionHandle(MethodArgumentTypeMismatchException e) { + return ApiResponse.error(GlobalErrorCode.INVALID_TYPE.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(BindException.class) + public ResponseEntity bindException(BindException e) { + log.error("bindException", e); + + return ApiResponse.error(e.getBindingResult().getFieldError().getDefaultMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(GlobalRuntimeException.class) + public ResponseEntity globalRuntimeExceptionHandle(GlobalRuntimeException e) { + log.error("globalRuntimeExceptionHandle", e); + + return ApiResponse.error(e.getErrorCode()); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity runtimeExceptionHandle(RuntimeException e) { + log.error("runtimeExceptionHandle", e); + + return ApiResponse.error(e.getCause().getCause().getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity exceptionHandle(Exception e) { + log.error("exceptionHandle", e); + + return ApiResponse.error(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/livable/server/core/exception/GlobalRuntimeException.java b/src/main/java/com/livable/server/core/exception/GlobalRuntimeException.java new file mode 100644 index 00000000..7b27b17d --- /dev/null +++ b/src/main/java/com/livable/server/core/exception/GlobalRuntimeException.java @@ -0,0 +1,14 @@ +package com.livable.server.core.exception; + +import lombok.Getter; + +@Getter +public class GlobalRuntimeException extends RuntimeException { + + private final ErrorCode errorCode; + + public GlobalRuntimeException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/livable/server/core/response/ApiResponse.java b/src/main/java/com/livable/server/core/response/ApiResponse.java new file mode 100644 index 00000000..eeeafc81 --- /dev/null +++ b/src/main/java/com/livable/server/core/response/ApiResponse.java @@ -0,0 +1,56 @@ +package com.livable.server.core.response; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.NonNull; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + public static ResponseEntity> success(@NonNull HttpStatus httpStatus) { + return ResponseEntity.status(httpStatus) + .build(); + } + + public static ResponseEntity> success(@NonNull T data, @NonNull HttpStatus httpStatus) { + return ResponseEntity.status(httpStatus) + .body(Success.of(data)); + } + + public static ResponseEntity error(@NonNull String message, @NonNull HttpStatus httpStatus) { + return ResponseEntity.status(httpStatus) + .body(Error.of(message)); + } + + public static ResponseEntity error(@NonNull ErrorCode errorCode) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(Error.of(errorCode.getMessage())); + } + + @Getter + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class Error { + + private String message; + + public static Error of(@NonNull String errorMessage) { + return new Error(errorMessage); + } + } + + @Getter + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class Success { + + private T data; + + public static Success of(@NonNull T data) { + return new Success<>(data); + } + } +} diff --git a/src/main/java/com/livable/server/core/util/Actor.java b/src/main/java/com/livable/server/core/util/Actor.java new file mode 100644 index 00000000..0978c3c0 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/Actor.java @@ -0,0 +1,12 @@ +package com.livable.server.core.util; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class Actor { + + private Long id; + private ActorType actorType; +} diff --git a/src/main/java/com/livable/server/core/util/ActorType.java b/src/main/java/com/livable/server/core/util/ActorType.java new file mode 100644 index 00000000..e3f89125 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/ActorType.java @@ -0,0 +1,18 @@ +package com.livable.server.core.util; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.member.domain.MemberErrorCode; + +import java.util.Arrays; + +public enum ActorType { + MEMBER, VISITOR, ADMIN; + + public static ActorType of(String type) { + return Arrays.stream(values()) + .filter(actorType -> actorType.name().equals(type)) + .findFirst() + .orElseThrow(() -> new GlobalRuntimeException(MemberErrorCode.INVALID_ACTOR_TYPE)); + } +} + diff --git a/src/main/java/com/livable/server/core/util/ImageSeparator.java b/src/main/java/com/livable/server/core/util/ImageSeparator.java new file mode 100644 index 00000000..0e6b92c9 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/ImageSeparator.java @@ -0,0 +1,26 @@ +package com.livable.server.core.util; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +@Component +public class ImageSeparator { + + public static final String IMAGE_SEPARATOR = ","; + + /** + * IMAGE_SEPARATOR로 이어진 이미지 url 문자열을 리스트로 분리하여 반환한다. + * @param concatenatedImageUrl + * @return 분리된 url 리스트 + */ + public List separateConcatenatedImages(String concatenatedImageUrl) { + if (Objects.isNull(concatenatedImageUrl)) { + return new ArrayList<>(); + } + return Arrays.asList(concatenatedImageUrl.split(IMAGE_SEPARATOR)); + } +} diff --git a/src/main/java/com/livable/server/core/util/JwtTokenProvider.java b/src/main/java/com/livable/server/core/util/JwtTokenProvider.java new file mode 100644 index 00000000..2a44ad2d --- /dev/null +++ b/src/main/java/com/livable/server/core/util/JwtTokenProvider.java @@ -0,0 +1,82 @@ +package com.livable.server.core.util; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.member.domain.MemberErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private final Key secretKey; + + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + public String createActorToken(ActorType actorType, Long actorId, Date expireDate) { + + Claims claims = Jwts.claims(); + claims.put("actorId", actorId); + claims.put("actorType", actorType); + + return Jwts.builder() + .setClaims(claims) + .setExpiration(expireDate) + .signWith(secretKey) + .compact(); + } + + public boolean isValidateToken(String token) { + try { + Jws claimsJws = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + + return claimsJws.getBody().getExpiration().after(new Date()); + } catch (Exception e) { + return false; + } + } + + public Claims parseClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (JwtException e) { + e.printStackTrace(); + throw new GlobalRuntimeException(MemberErrorCode.INVALID_TOKEN); + } + } + + public static void checkMemberToken(Actor actor) { + if (actor.getActorType() != ActorType.MEMBER) { + throw new GlobalRuntimeException(MemberErrorCode.INVALID_TOKEN); + } + } + + public static void checkVisitorToken(Actor actor) { + if (actor.getActorType() != ActorType.VISITOR) { + throw new GlobalRuntimeException(MemberErrorCode.INVALID_TOKEN); + } + } + + public static void checkAdminToken(Actor actor) { + if (actor.getActorType() != ActorType.ADMIN) { + throw new GlobalRuntimeException(MemberErrorCode.INVALID_TOKEN); + } + } + +} diff --git a/src/main/java/com/livable/server/core/util/LoginActor.java b/src/main/java/com/livable/server/core/util/LoginActor.java new file mode 100644 index 00000000..8147e17c --- /dev/null +++ b/src/main/java/com/livable/server/core/util/LoginActor.java @@ -0,0 +1,11 @@ +package com.livable.server.core.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface LoginActor { +} diff --git a/src/main/java/com/livable/server/core/util/LoginActorArgumentResolver.java b/src/main/java/com/livable/server/core/util/LoginActorArgumentResolver.java new file mode 100644 index 00000000..e5dadddb --- /dev/null +++ b/src/main/java/com/livable/server/core/util/LoginActorArgumentResolver.java @@ -0,0 +1,43 @@ +package com.livable.server.core.util; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Slf4j +public class LoginActorArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtTokenProvider tokenProvider; + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginActor.class) && parameter.getParameterType().equals(Actor.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) throws Exception { + String authorization = webRequest.getHeader("Authorization"); + String token = authorization.split("Bearer ")[1]; + + Claims claims = tokenProvider.parseClaims(token); + Long actorId = claims.get("actorId", Long.class); + String actorType = claims.get("actorType", String.class); + + return Actor.builder() + .id(actorId) + .actorType(ActorType.of(actorType)) + .build(); + } +} diff --git a/src/main/java/com/livable/server/core/util/PresentDate.java b/src/main/java/com/livable/server/core/util/PresentDate.java new file mode 100644 index 00000000..a151d091 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/PresentDate.java @@ -0,0 +1,19 @@ +package com.livable.server.core.util; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.validation.Constraint; +import javax.validation.Payload; + +@Documented +@Constraint(validatedBy = PresentDateValidator.class) +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface PresentDate { + String message() default "Date는 오늘 날짜와 같아야 합니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/com/livable/server/core/util/PresentDateValidator.java b/src/main/java/com/livable/server/core/util/PresentDateValidator.java new file mode 100644 index 00000000..35a2096c --- /dev/null +++ b/src/main/java/com/livable/server/core/util/PresentDateValidator.java @@ -0,0 +1,21 @@ +package com.livable.server.core.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class PresentDateValidator implements ConstraintValidator { + @Override + public boolean isValid(LocalDate date, ConstraintValidatorContext context) { + + if (date == null) { + return false; + } + + LocalDate currentDate = LocalDateTime.now().toLocalDate(); + + return date.isEqual(currentDate); + + } +} diff --git a/src/main/java/com/livable/server/core/util/S3Uploader.java b/src/main/java/com/livable/server/core/util/S3Uploader.java new file mode 100644 index 00000000..9f95b371 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/S3Uploader.java @@ -0,0 +1,80 @@ +package com.livable.server.core.util; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@RequiredArgsConstructor +@Component +public class S3Uploader { + + private static final List ALLOWED_EXTENSIONS = List.of("jpg", "png", "gif", "jpeg"); + private static final String EXTENSION_SEPARATOR = "."; + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public List saveFile(List files) throws IOException { + if (Objects.isNull(files) || files.isEmpty()) { + return new ArrayList<>(); + } + + List accessUrls = new ArrayList<>(); + + for (MultipartFile file : files) { + String accessUrl = saveFile(file); + accessUrls.add(accessUrl); + } + return accessUrls; + } + + public String saveFile(MultipartFile file) throws IOException { + + String filename = file.getOriginalFilename(); + final String originalFileName = filename.replaceAll(ImageSeparator.IMAGE_SEPARATOR, ""); + + final String fileExtension = getFileExtension(originalFileName); + + validationAllowedFileExtension(fileExtension); + String randomFileName = generateRandomFileName(originalFileName, fileExtension); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + amazonS3.putObject(bucket, randomFileName, file.getInputStream(), metadata); + + return amazonS3.getUrl(bucket, randomFileName).toString(); + } + + // 랜덤한 파일 이름을 생성하는 메서드 + private String generateRandomFileName(String originalFileName, String fileExtension) { + return UUID.randomUUID() + originalFileName + EXTENSION_SEPARATOR + fileExtension; + } + + // 파일 이름에서 파일 확장자를 추출하는 메서드 + private String getFileExtension(String originalFileName) { + return originalFileName + .substring(originalFileName.lastIndexOf(EXTENSION_SEPARATOR) + 1) + .toLowerCase(); + + } + + // 파일 확장자를 검증하는 메서드 + private void validationAllowedFileExtension(String fileExtension) { + if (!ALLOWED_EXTENSIONS.contains(fileExtension)) { + throw new IllegalArgumentException("지원하지 않는 파일 확장자 입니다."); + } + } +} diff --git a/src/main/java/com/livable/server/core/util/StringToLocalDateConverter.java b/src/main/java/com/livable/server/core/util/StringToLocalDateConverter.java new file mode 100644 index 00000000..7c1ed921 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/StringToLocalDateConverter.java @@ -0,0 +1,21 @@ +package com.livable.server.core.util; + +import com.livable.server.core.exception.GlobalErrorCode; +import com.livable.server.core.exception.GlobalRuntimeException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.convert.converter.Converter; + +import java.time.LocalDate; + +@Slf4j +public class StringToLocalDateConverter implements Converter { + + @Override + public LocalDate convert(String source) { + try { + return LocalDate.parse(source); + } catch (RuntimeException e) { + throw new GlobalRuntimeException(GlobalErrorCode.INVALID_TYPE); + } + } +} diff --git a/src/main/java/com/livable/server/core/util/StringToRestaurantCategoryConverter.java b/src/main/java/com/livable/server/core/util/StringToRestaurantCategoryConverter.java new file mode 100644 index 00000000..824574ec --- /dev/null +++ b/src/main/java/com/livable/server/core/util/StringToRestaurantCategoryConverter.java @@ -0,0 +1,12 @@ +package com.livable.server.core.util; + +import com.livable.server.entity.RestaurantCategory; +import org.springframework.core.convert.converter.Converter; + +public class StringToRestaurantCategoryConverter implements Converter { + + @Override + public RestaurantCategory convert(String event) { + return RestaurantCategory.of(event); + } +} diff --git a/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/domain/CronSchedule.java b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/domain/CronSchedule.java new file mode 100644 index 00000000..422f74cb --- /dev/null +++ b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/domain/CronSchedule.java @@ -0,0 +1,14 @@ +package com.livable.server.core.util.scheduler.menuchoiceresult.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CronSchedule { + + EVERY_DAY_OF_ZERO("0 0 0 1/1 * ? *"), + EVERY_SUNDAY_OF_END("0 45 23 ? * SUN *"); + + private final String croneSchedule; +} diff --git a/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/job/MenuChoiceResultDailyJob.java b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/job/MenuChoiceResultDailyJob.java new file mode 100644 index 00000000..87c3dce8 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/job/MenuChoiceResultDailyJob.java @@ -0,0 +1,38 @@ +package com.livable.server.core.util.scheduler.menuchoiceresult.job; + +import com.livable.server.menu.service.MenuChoiceResultService; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +@RequiredArgsConstructor +@Slf4j +public class MenuChoiceResultDailyJob implements Job { + + private final MenuChoiceResultService menuChoiceResultService; + + @Override + public void execute(JobExecutionContext context) { + + //Date of Job executed + Long milliseconds = context.getFireTime().getTime(); + + LocalDate referenceDate = millisecondsToDate(milliseconds); + + menuChoiceResultService.createDailyMenuChoiceResult(referenceDate); + + } + + private LocalDate millisecondsToDate(Long milliseconds) { + Instant instant = Instant.ofEpochMilli(milliseconds); + + ZoneId zoneId = ZoneId.systemDefault(); + return instant.atZone(zoneId).toLocalDate(); + } + + +} diff --git a/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/job/MenuChoiceResultWeeklyJob.java b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/job/MenuChoiceResultWeeklyJob.java new file mode 100644 index 00000000..28627a9a --- /dev/null +++ b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/job/MenuChoiceResultWeeklyJob.java @@ -0,0 +1,37 @@ +package com.livable.server.core.util.scheduler.menuchoiceresult.job; + +import com.livable.server.menu.service.MenuChoiceResultService; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +@RequiredArgsConstructor +@Slf4j +public class MenuChoiceResultWeeklyJob implements Job { + + private final MenuChoiceResultService menuChoiceResultService; + + @Override + public void execute(JobExecutionContext context) { + + //Date of Job executed + Long milliseconds = context.getFireTime().getTime(); + + LocalDate referenceDate = millisecondsToDate(milliseconds); + + menuChoiceResultService.createWeeklyMenuChoiceResult(referenceDate); + + } + private LocalDate millisecondsToDate(Long milliseconds) { + Instant instant = Instant.ofEpochMilli(milliseconds); + + ZoneId zoneId = ZoneId.systemDefault(); + return instant.atZone(zoneId).toLocalDate(); + } + + +} diff --git a/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/listener/MenuChoiceJobListener.java b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/listener/MenuChoiceJobListener.java new file mode 100644 index 00000000..61fead2f --- /dev/null +++ b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/listener/MenuChoiceJobListener.java @@ -0,0 +1,35 @@ +package com.livable.server.core.util.scheduler.menuchoiceresult.listener; + +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.JobKey; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class MenuChoiceJobListener implements org.quartz.JobListener { + + @Override + public String getName() { + return MenuChoiceJobListener.class.getName(); + } + + @Override + public void jobToBeExecuted(JobExecutionContext context) { + JobKey jobKey = context.getJobDetail().getKey(); + log.info("Job Executed : JobKey : {}",jobKey); + } + + @Override + public void jobExecutionVetoed(JobExecutionContext context) { + JobKey jobKey = context.getJobDetail().getKey(); + log.info("Job ExecutionVetoed : JobKey : {}",jobKey); + } + + @Override + public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) { + JobKey jobKey = context.getJobDetail().getKey(); + log.info("Job Was Executed : JobKey : {}",jobKey); + } +} diff --git a/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/listener/MenuChoiceTriggerListener.java b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/listener/MenuChoiceTriggerListener.java new file mode 100644 index 00000000..0848aec6 --- /dev/null +++ b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/listener/MenuChoiceTriggerListener.java @@ -0,0 +1,42 @@ +package com.livable.server.core.util.scheduler.menuchoiceresult.listener; + +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobExecutionContext; +import org.quartz.JobKey; +import org.quartz.Trigger; +import org.quartz.Trigger.CompletedExecutionInstruction; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class MenuChoiceTriggerListener implements org.quartz.TriggerListener { + + @Override + public String getName() { + return MenuChoiceTriggerListener.class.getName(); + } + + @Override + public void triggerFired(Trigger trigger, JobExecutionContext context) { + JobKey jobKey = trigger.getJobKey(); + log.info("Trigger Fired at {} : JobKey : {}", trigger.getStartTime(),jobKey); + } + + @Override + public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) { + return false; + } + + @Override + public void triggerMisfired(Trigger trigger) { + JobKey jobKey = trigger.getJobKey(); + log.info("Trigger MisFired at {} : JobKey : {}", trigger.getStartTime(),jobKey); + } + + @Override + public void triggerComplete(Trigger trigger, JobExecutionContext context, + CompletedExecutionInstruction triggerInstructionCode) { + JobKey jobKey = trigger.getJobKey(); + log.info("Trigger Complete at {} : JobKey : {}", trigger.getStartTime(),jobKey); + } +} diff --git a/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/service/MenuChoiceQuartzService.java b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/service/MenuChoiceQuartzService.java new file mode 100644 index 00000000..bbd5220d --- /dev/null +++ b/src/main/java/com/livable/server/core/util/scheduler/menuchoiceresult/service/MenuChoiceQuartzService.java @@ -0,0 +1,81 @@ +package com.livable.server.core.util.scheduler.menuchoiceresult.service; + +import static org.quartz.CronScheduleBuilder.cronSchedule; + +import com.livable.server.core.util.scheduler.menuchoiceresult.domain.CronSchedule; +import com.livable.server.core.util.scheduler.menuchoiceresult.job.MenuChoiceResultWeeklyJob; +import com.livable.server.core.util.scheduler.menuchoiceresult.listener.MenuChoiceJobListener; +import com.livable.server.core.util.scheduler.menuchoiceresult.listener.MenuChoiceTriggerListener; +import com.livable.server.core.util.scheduler.menuchoiceresult.job.MenuChoiceResultDailyJob; +import java.util.TimeZone; +import java.util.UUID; +import javax.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.CronTrigger; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.ListenerManager; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.TriggerBuilder; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MenuChoiceQuartzService { + + private final SchedulerFactoryBean schedulerFactory; + + private static final String GROUP_NAME = "Menu Choice Result"; + + @PostConstruct + public void scheduled() { + + Scheduler scheduler = schedulerFactory.getScheduler(); + + JobDetail menuChoiceResultDailyJob = menuChoiceResultJobDetail(MenuChoiceResultDailyJob.class); + CronTrigger menuChoiceResultDailyCronTrigger = menuChoiceResultCronTrigger(menuChoiceResultDailyJob, CronSchedule.EVERY_DAY_OF_ZERO.getCroneSchedule(), "가장 많이 선택된 메뉴 집계 Daily Trigger"); + + JobDetail menuChoiceResultWeeklyJob = menuChoiceResultJobDetail(MenuChoiceResultWeeklyJob.class); + CronTrigger menuChoiceResultWeeklyCronTrigger = menuChoiceResultCronTrigger(menuChoiceResultWeeklyJob, CronSchedule.EVERY_SUNDAY_OF_END.getCroneSchedule(), "가장 많이 선택된 메뉴 집계 WeeklyTrigger"); + + try { + //Listener Setting + ListenerManager listenerManager = scheduler.getListenerManager(); + listenerManager.addJobListener(new MenuChoiceJobListener()); + listenerManager.addTriggerListener(new MenuChoiceTriggerListener()); + + //Add Job & Trigger to schedule + scheduler.scheduleJob(menuChoiceResultDailyJob, menuChoiceResultDailyCronTrigger); + scheduler.scheduleJob(menuChoiceResultWeeklyJob, menuChoiceResultWeeklyCronTrigger); + + } catch (SchedulerException e) { + log.error("Menu Choice Result Scheduler Exception Occurred : {}", menuChoiceResultDailyJob.getKey()); + } + } + + public JobDetail menuChoiceResultJobDetail(Class clazz) { + + return JobBuilder.newJob(clazz) + .withIdentity(UUID.randomUUID().toString(), GROUP_NAME) + .withDescription(clazz.getSimpleName()) + .build(); + } + + public CronTrigger menuChoiceResultCronTrigger(JobDetail jobDetail, String schedule, String description) { + return TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(jobDetail.getKey().getName(), GROUP_NAME) + .withDescription(description) + .withSchedule(cronSchedule(schedule) + .inTimeZone(TimeZone.getDefault()) + .withMisfireHandlingInstructionFireAndProceed() + ) + .build(); + } + +} diff --git a/src/main/java/com/livable/server/entity/Admin.java b/src/main/java/com/livable/server/entity/Admin.java new file mode 100644 index 00000000..b9f16c99 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Admin.java @@ -0,0 +1,20 @@ +package com.livable.server.entity; + +import lombok.Getter; + +import javax.persistence.*; + +@Getter +@Entity +public class Admin extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String email; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false, unique = true) + private Building building; +} diff --git a/src/main/java/com/livable/server/entity/BaseTimeEntity.java b/src/main/java/com/livable/server/entity/BaseTimeEntity.java new file mode 100644 index 00000000..72f35efb --- /dev/null +++ b/src/main/java/com/livable/server/entity/BaseTimeEntity.java @@ -0,0 +1,23 @@ +package com.livable.server.entity; + + +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/livable/server/entity/Building.java b/src/main/java/com/livable/server/entity/Building.java new file mode 100644 index 00000000..be0eafaa --- /dev/null +++ b/src/main/java/com/livable/server/entity/Building.java @@ -0,0 +1,51 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Building extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Boolean hasCafeteria; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String address; + + @Column(nullable = false) + private LocalTime startTime; + + @Column(nullable = false) + private LocalTime endTime; + + @Column(nullable = false) + private String scale; + + @Column(nullable = false) + private String longitude; + + @Column(nullable = false) + private String latitude; + + @Column(nullable = false) + private String representativeImageUrl; + + @Column(nullable = false) + private String parkingCostInformation; + + @Column + private String subwayStation; +} diff --git a/src/main/java/com/livable/server/entity/BuildingRestaurantMap.java b/src/main/java/com/livable/server/entity/BuildingRestaurantMap.java new file mode 100644 index 00000000..c0c5c41c --- /dev/null +++ b/src/main/java/com/livable/server/entity/BuildingRestaurantMap.java @@ -0,0 +1,39 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "building_restaurant_map", + uniqueConstraints = + @UniqueConstraint( + name = "BUILDING_RESTAURANT_UNIQUE_IDX", + columnNames = {"building_id", "restaurant_id"} + ) +) +@Entity +public class BuildingRestaurantMap extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, name = "building_id") + @ManyToOne(fetch = FetchType.LAZY) + private Building building; + + @JoinColumn(nullable = false, name = "restaurant_id") + @ManyToOne(fetch = FetchType.LAZY) + private Restaurant restaurant; + + @Column(nullable = false) + private Boolean inBuilding; + + @Column(nullable = false) + private Integer distance; +} diff --git a/src/main/java/com/livable/server/entity/CafeteriaReview.java b/src/main/java/com/livable/server/entity/CafeteriaReview.java new file mode 100644 index 00000000..8333eb18 --- /dev/null +++ b/src/main/java/com/livable/server/entity/CafeteriaReview.java @@ -0,0 +1,23 @@ +package com.livable.server.entity; + +import lombok.*; +import lombok.experimental.SuperBuilder; + +import javax.persistence.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +@Entity +@ToString(callSuper = true) +public class CafeteriaReview extends Review { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Evaluation taste; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Building building; +} diff --git a/src/main/java/com/livable/server/entity/CommonPlace.java b/src/main/java/com/livable/server/entity/CommonPlace.java new file mode 100644 index 00000000..5ee2aa00 --- /dev/null +++ b/src/main/java/com/livable/server/entity/CommonPlace.java @@ -0,0 +1,30 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class CommonPlace extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Building building; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String floor; + + @Column(nullable = false) + private String roomNumber; +} diff --git a/src/main/java/com/livable/server/entity/Company.java b/src/main/java/com/livable/server/entity/Company.java new file mode 100644 index 00000000..02317c5f --- /dev/null +++ b/src/main/java/com/livable/server/entity/Company.java @@ -0,0 +1,24 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Company extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Building building; + + @Column(nullable = false) + private String name; +} diff --git a/src/main/java/com/livable/server/entity/Evaluation.java b/src/main/java/com/livable/server/entity/Evaluation.java new file mode 100644 index 00000000..fdbdfec2 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Evaluation.java @@ -0,0 +1,7 @@ +package com.livable.server.entity; + +public enum Evaluation { + + GOOD, + BAD; +} diff --git a/src/main/java/com/livable/server/entity/Invitation.java b/src/main/java/com/livable/server/entity/Invitation.java new file mode 100644 index 00000000..4ba52ddc --- /dev/null +++ b/src/main/java/com/livable/server/entity/Invitation.java @@ -0,0 +1,56 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Invitation extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @Column + private String description; + + @Column(nullable = false) + private String purpose; + + @Column(nullable = false) + private String officeName; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private LocalDate endDate; + + @Column(nullable = false) + private LocalTime startTime; + + @Column(nullable = false) + private LocalTime endTime; + + public void updateDateTime(LocalDateTime startDateTime, LocalDateTime endDateTime) { + this.startDate = startDateTime.toLocalDate(); + this.startTime = startDateTime.toLocalTime(); + this.endDate = endDateTime.toLocalDate(); + this.endTime = endDateTime.toLocalTime(); + } + + public void updateDescription(String description) { + this.description = description; + } +} diff --git a/src/main/java/com/livable/server/entity/InvitationReservationMap.java b/src/main/java/com/livable/server/entity/InvitationReservationMap.java new file mode 100644 index 00000000..a3a29de5 --- /dev/null +++ b/src/main/java/com/livable/server/entity/InvitationReservationMap.java @@ -0,0 +1,25 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class InvitationReservationMap extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Invitation invitation; + + @JoinColumn(nullable = false, unique = true) + @OneToOne(fetch = FetchType.LAZY) + private Reservation reservation; +} diff --git a/src/main/java/com/livable/server/entity/LunchBoxReview.java b/src/main/java/com/livable/server/entity/LunchBoxReview.java new file mode 100644 index 00000000..a04f7bc8 --- /dev/null +++ b/src/main/java/com/livable/server/entity/LunchBoxReview.java @@ -0,0 +1,15 @@ +package com.livable.server.entity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import javax.persistence.Entity; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +@Entity +public class LunchBoxReview extends Review { +} diff --git a/src/main/java/com/livable/server/entity/Member.java b/src/main/java/com/livable/server/entity/Member.java new file mode 100644 index 00000000..32511131 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Member.java @@ -0,0 +1,46 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Company company; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String profileImageUrl; + + @Column + private String businessCardImageUrl; + + @Column(nullable = false, unique = true) + private String contact; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String employeeNumber; +} diff --git a/src/main/java/com/livable/server/entity/Menu.java b/src/main/java/com/livable/server/entity/Menu.java new file mode 100644 index 00000000..baa1c61a --- /dev/null +++ b/src/main/java/com/livable/server/entity/Menu.java @@ -0,0 +1,27 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Menu extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private MenuCategory menuCategory; + + @Column(nullable = false, unique = true) + private String name; + + @Column(nullable = false, length = 1000) + private String representativeImageUrl; +} diff --git a/src/main/java/com/livable/server/entity/MenuCategory.java b/src/main/java/com/livable/server/entity/MenuCategory.java new file mode 100644 index 00000000..ca4fd9ea --- /dev/null +++ b/src/main/java/com/livable/server/entity/MenuCategory.java @@ -0,0 +1,20 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class MenuCategory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; +} diff --git a/src/main/java/com/livable/server/entity/MenuChoiceLog.java b/src/main/java/com/livable/server/entity/MenuChoiceLog.java new file mode 100644 index 00000000..19ad9dc3 --- /dev/null +++ b/src/main/java/com/livable/server/entity/MenuChoiceLog.java @@ -0,0 +1,45 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "menu_choice_log", + uniqueConstraints = + @UniqueConstraint( + name = "MEMBER_DATE_UNIQUE_IDX", + columnNames = {"member_id", "date"} + ) +) +@Entity +public class MenuChoiceLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, name = "member_id") + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Building building; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; + + @Column(nullable = false, name = "date") + private LocalDate date; + + public void updateMenu(Menu changedMenu) { + this.menu = changedMenu; + } +} diff --git a/src/main/java/com/livable/server/entity/MenuChoiceResult.java b/src/main/java/com/livable/server/entity/MenuChoiceResult.java new file mode 100644 index 00000000..eed7411a --- /dev/null +++ b/src/main/java/com/livable/server/entity/MenuChoiceResult.java @@ -0,0 +1,40 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "menu_choice_result", + uniqueConstraints = + @UniqueConstraint( + name = "MEMBER_BUILDING_DATE_UNIQUE_IDX", + columnNames = {"building_id", "menu_id", "date"} + ) +) +@Entity +public class MenuChoiceResult extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, name = "building_id") + @ManyToOne(fetch = FetchType.LAZY) + private Building building; + + @JoinColumn(nullable = false, name = "menu_id") + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; + + @Column(nullable = false, name = "date") + private LocalDate date; + + @Column(nullable = false) + private Integer count; +} diff --git a/src/main/java/com/livable/server/entity/MenuChoiceWeeklyResult.java b/src/main/java/com/livable/server/entity/MenuChoiceWeeklyResult.java new file mode 100644 index 00000000..33376fe2 --- /dev/null +++ b/src/main/java/com/livable/server/entity/MenuChoiceWeeklyResult.java @@ -0,0 +1,52 @@ +package com.livable.server.entity; + +import java.time.LocalDate; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "menu_choice_weekly_result", + uniqueConstraints = + @UniqueConstraint( + name = "MENU_BUILDING_DATE_UNIQUE_IDX", + columnNames = {"building_id", "menu_id", "date"} + ) +) +@Entity +public class MenuChoiceWeeklyResult extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, name = "building_id") + @ManyToOne(fetch = FetchType.LAZY) + private Building building; + + @JoinColumn(nullable = false, name = "menu_id") + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; + + @Column(nullable = false, name = "date") + private LocalDate date; + + @Column(nullable = false) + private Integer count; +} diff --git a/src/main/java/com/livable/server/entity/Office.java b/src/main/java/com/livable/server/entity/Office.java new file mode 100644 index 00000000..0dd955a6 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Office.java @@ -0,0 +1,30 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Office extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Company company; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String floor; + + @Column(nullable = false) + private String roomNumber; +} diff --git a/src/main/java/com/livable/server/entity/ParkingLog.java b/src/main/java/com/livable/server/entity/ParkingLog.java new file mode 100644 index 00000000..efa86134 --- /dev/null +++ b/src/main/java/com/livable/server/entity/ParkingLog.java @@ -0,0 +1,41 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ParkingLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, unique = true) + @OneToOne(fetch = FetchType.LAZY) + private Visitor visitor; + + @Column(nullable = false) + private String carNumber; + + @Column + private LocalDateTime inTime; + + @Column + private LocalDateTime outTime; + + @Column + private Integer stayTime; + + public static ParkingLog create(Visitor visitor, String carNumber) { + return ParkingLog.builder() + .carNumber(carNumber) + .visitor(visitor) + .build(); + } +} diff --git a/src/main/java/com/livable/server/entity/Point.java b/src/main/java/com/livable/server/entity/Point.java new file mode 100644 index 00000000..230aac55 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Point.java @@ -0,0 +1,28 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Point extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, unique = true) + @OneToOne(fetch = FetchType.LAZY) + private Member member; + + @Column(nullable = false) + private Integer balance; + + public void plusPoint(Integer amount) { + this.balance += amount; + } +} diff --git a/src/main/java/com/livable/server/entity/PointCode.java b/src/main/java/com/livable/server/entity/PointCode.java new file mode 100644 index 00000000..3d9518f7 --- /dev/null +++ b/src/main/java/com/livable/server/entity/PointCode.java @@ -0,0 +1,26 @@ +package com.livable.server.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public enum PointCode { + + PA00("외부 식당 오점완 리뷰 작성을 통해 포인트 획득"), + PA01("구내 식당 오점완 리뷰 작성을 통해 포인트 획득"), + PA02("도시락 오점완 리뷰 작성을 통해 포인트 획득"), + PA03("오점완 7일차 달성 보상"), + PA04("오점완 14일차 달성 보상"), + PA05("오점완 21일차 달성 보상"), + PA06("오점완 28일차 달성 보상"), + PM00("제휴 카페 메뉴 할인에 대한 포인트 사용"); + + private final String description; + + public static List getReviewPointCodes() { + return List.of(PA00, PA01, PA02); + } +} diff --git a/src/main/java/com/livable/server/entity/PointLog.java b/src/main/java/com/livable/server/entity/PointLog.java new file mode 100644 index 00000000..cff5c029 --- /dev/null +++ b/src/main/java/com/livable/server/entity/PointLog.java @@ -0,0 +1,49 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class PointLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Point point; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Review review; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PointCode code; + + @Column(nullable = false) + private Integer amount; + + @Column(nullable = false) + private LocalDateTime paidAt; + + @PrePersist + private void prePersist() { + paidAt = LocalDateTime.now(); + } + + public boolean isPaid(LocalDate date) { + LocalDateTime paidDateTime = this.getPaidAt(); + LocalDate paidDate = LocalDate.of(paidDateTime.getYear(), paidDateTime.getMonth(), paidDateTime.getDayOfMonth()); + + return paidDate.equals(date); + } +} diff --git a/src/main/java/com/livable/server/entity/Reservation.java b/src/main/java/com/livable/server/entity/Reservation.java new file mode 100644 index 00000000..ec23e5d5 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Reservation.java @@ -0,0 +1,41 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "reservation", + uniqueConstraints = + @UniqueConstraint( + name = "COMMON_PLACE_DATE_TIME_UNIQUE_IDX", + columnNames = {"common_place_id", "date", "time"} + ) +) +@Entity +public class Reservation extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Company company; + + @JoinColumn(nullable = false, name = "common_place_id") + @ManyToOne(fetch = FetchType.LAZY) + private CommonPlace commonPlace; + + @Column(nullable = false, name = "date") + private LocalDate date; + + @Column(nullable = false, name = "time") + private LocalTime time; +} diff --git a/src/main/java/com/livable/server/entity/Restaurant.java b/src/main/java/com/livable/server/entity/Restaurant.java new file mode 100644 index 00000000..368d0d11 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Restaurant.java @@ -0,0 +1,69 @@ +package com.livable.server.entity; + +import com.livable.server.restaurant.dto.RestaurantByMenuProjection; +import javax.persistence.Column; +import javax.persistence.ColumnResult; +import javax.persistence.ConstructorResult; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.SqlResultSetMapping; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@SqlResultSetMapping( + name ="RestaurantsByMenuMapping", + classes = @ConstructorResult( + targetClass = RestaurantByMenuProjection.class, + columns = { + @ColumnResult(name = "restaurantId", type = Long.class), + @ColumnResult(name = "restaurantName", type = String.class), + @ColumnResult(name = "restaurantThumbnailUrl", type = String.class), + @ColumnResult(name = "address", type = String.class), + @ColumnResult(name = "inBuilding", type = Boolean.class), + @ColumnResult(name = "distance", type = Integer.class), + @ColumnResult(name = "review", type = String.class), + @ColumnResult(name = "tastePercentage", type = Integer.class), + } + ) +) + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Restaurant extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RestaurantCategory restaurantCategory; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String contact; + + @Column(nullable = false) + private String address; + + @Column(nullable = false) + private String restaurantUrl; + + @Column(nullable = false) + private String thumbnailImageUrl; + + @Column(nullable = false) + private String representativeCategory; +} diff --git a/src/main/java/com/livable/server/entity/RestaurantCategory.java b/src/main/java/com/livable/server/entity/RestaurantCategory.java new file mode 100644 index 00000000..fb3e1144 --- /dev/null +++ b/src/main/java/com/livable/server/entity/RestaurantCategory.java @@ -0,0 +1,26 @@ +package com.livable.server.entity; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.restaurant.domain.RestaurantErrorCode; + +import java.util.Arrays; +import java.util.List; + +public enum RestaurantCategory { + + RESTAURANT(List.of("RESTAURANT", "restaurant", "Restaurant")), + CAFE(List.of("CAFE", "cafe", "Cafe")); + + private final List symbols; + + RestaurantCategory(List symbols) { + this.symbols = symbols; + } + + public static RestaurantCategory of(String symbol) { + return Arrays.stream(values()) + .filter(restaurantCategory -> restaurantCategory.symbols.contains(symbol)) + .findFirst() + .orElseThrow(() -> new GlobalRuntimeException(RestaurantErrorCode.NOT_FOUND_CATEGORY)); + } +} diff --git a/src/main/java/com/livable/server/entity/RestaurantMenuMap.java b/src/main/java/com/livable/server/entity/RestaurantMenuMap.java new file mode 100644 index 00000000..ac70ae5a --- /dev/null +++ b/src/main/java/com/livable/server/entity/RestaurantMenuMap.java @@ -0,0 +1,33 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "restaurant_menu_map", + uniqueConstraints = + @UniqueConstraint( + name = "RESTAURANT_MENU_UNIQUE_IDX", + columnNames = {"restaurant_id", "menu_id"} + ) +) +@Entity +public class RestaurantMenuMap extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false, name = "restaurant_id") + @ManyToOne(fetch = FetchType.LAZY) + private Restaurant restaurant; + + @JoinColumn(nullable = false, name = "menu_id") + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; +} diff --git a/src/main/java/com/livable/server/entity/RestaurantReview.java b/src/main/java/com/livable/server/entity/RestaurantReview.java new file mode 100644 index 00000000..40a968bb --- /dev/null +++ b/src/main/java/com/livable/server/entity/RestaurantReview.java @@ -0,0 +1,37 @@ +package com.livable.server.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import javax.persistence.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +@Entity +public class RestaurantReview extends Review { + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Restaurant restaurant; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Evaluation taste; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Evaluation speed; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Evaluation amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Evaluation service; +} diff --git a/src/main/java/com/livable/server/entity/Review.java b/src/main/java/com/livable/server/entity/Review.java new file mode 100644 index 00000000..789b1175 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Review.java @@ -0,0 +1,75 @@ +package com.livable.server.entity; + +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.ReviewResponse; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@SqlResultSetMapping( + name = "ReviewAllList", + classes = @ConstructorResult( + targetClass = ReviewResponse.CalendarListDTO.class, + columns = { + @ColumnResult(name = "reviewId", type = Long.class), + @ColumnResult(name = "type", type = String.class), + @ColumnResult(name = "reviewImageUrl", type = String.class), + @ColumnResult(name = "reviewDate", type = LocalDate.class) + } + ) +) +@SqlResultSetMapping( + name = "AllReviewDetailListMapping", + classes = @ConstructorResult( + targetClass = Projection.AllReviewDetailDTO.class, + columns = { + @ColumnResult(name = "reviewId", type = Long.class), + @ColumnResult(name = "reviewTitle", type = String.class), + @ColumnResult(name = "reviewTaste", type = String.class), + @ColumnResult(name = "reviewDescription", type = String.class), + @ColumnResult(name = "reviewCreatedAt", type = String.class), + @ColumnResult(name = "location", type = String.class), + @ColumnResult(name = "images", type = String.class), + @ColumnResult(name = "reviewType", type = String.class) + } + ) +) +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Inheritance(strategy = InheritanceType.JOINED) +@EntityListeners(AuditingEntityListener.class) +@Entity +@ToString +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @Column(nullable = false) + private String description; + + @Column(nullable = false) + private String selectedDishes; + + @CreatedDate + @Column(nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/livable/server/entity/ReviewImage.java b/src/main/java/com/livable/server/entity/ReviewImage.java new file mode 100644 index 00000000..9fd5c499 --- /dev/null +++ b/src/main/java/com/livable/server/entity/ReviewImage.java @@ -0,0 +1,46 @@ +package com.livable.server.entity; + +import com.livable.server.review.dto.RestaurantReviewProjection; +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@SqlResultSetMapping( + name = "RestaurantReviewListMapping", + classes = @ConstructorResult( + targetClass = RestaurantReviewProjection.class, + columns = { + @ColumnResult(name = "memberName", type = String.class), + @ColumnResult(name = "memberProfileImage", type = String.class), + @ColumnResult(name = "restaurantId", type = Long.class), + @ColumnResult(name = "restaurantName", type = String.class), + @ColumnResult(name = "reviewId", type = Long.class), + @ColumnResult(name = "reviewCreatedAt", type = LocalDateTime.class), + @ColumnResult(name = "reviewDescription", type = String.class), + @ColumnResult(name = "reviewTaste", type = String.class), + @ColumnResult(name = "reviewAmount", type = String.class), + @ColumnResult(name = "reviewService", type = String.class), + @ColumnResult(name = "reviewSpeed", type = String.class), + @ColumnResult(name = "images", type = String.class), + } + ) +) +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class ReviewImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Review review; + + @Column(nullable = false) + private String url; +} diff --git a/src/main/java/com/livable/server/entity/ReviewMenuMap.java b/src/main/java/com/livable/server/entity/ReviewMenuMap.java new file mode 100644 index 00000000..df3fbf09 --- /dev/null +++ b/src/main/java/com/livable/server/entity/ReviewMenuMap.java @@ -0,0 +1,33 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "review_menu_map", + uniqueConstraints = + @UniqueConstraint( + name = "REVIEW_MENU_UNIQUE_IDX", + columnNames = {"review_Id", "menu_id"} + ) +) +@Entity +public class ReviewMenuMap extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Review review; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; +} diff --git a/src/main/java/com/livable/server/entity/Role.java b/src/main/java/com/livable/server/entity/Role.java new file mode 100644 index 00000000..307a887f --- /dev/null +++ b/src/main/java/com/livable/server/entity/Role.java @@ -0,0 +1,6 @@ +package com.livable.server.entity; + +public enum Role { + ADMIN, + USER; +} diff --git a/src/main/java/com/livable/server/entity/Visitor.java b/src/main/java/com/livable/server/entity/Visitor.java new file mode 100644 index 00000000..4f0f0661 --- /dev/null +++ b/src/main/java/com/livable/server/entity/Visitor.java @@ -0,0 +1,41 @@ +package com.livable.server.entity; + +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Visitor extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Invitation invitation; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String contact; + + @Column + private LocalDateTime firstVisitedTime; + + public void entrance() { + if (isFirstEntrance()) { + this.firstVisitedTime = LocalDateTime.now(); + } + } + + private boolean isFirstEntrance() { + return this.firstVisitedTime == null; + } +} diff --git a/src/main/java/com/livable/server/home/controller/HomeController.java b/src/main/java/com/livable/server/home/controller/HomeController.java new file mode 100644 index 00000000..df6c965c --- /dev/null +++ b/src/main/java/com/livable/server/home/controller/HomeController.java @@ -0,0 +1,46 @@ +package com.livable.server.home.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.home.dto.HomeResponse.AccessCardDto; +import com.livable.server.home.dto.HomeResponse.BuildingInfoDto; +import com.livable.server.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class HomeController { + + private final MemberService memberService; + + @GetMapping("api/home") + public ResponseEntity> getHomeInfo(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + BuildingInfoDto buildingInfoDto = memberService.getBuildingInfo(memberId); + + return ApiResponse.success(buildingInfoDto, HttpStatus.OK); + } + + @GetMapping("/api/access-card") + public ResponseEntity> getAccessCard(@LoginActor Actor actor){ + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + AccessCardDto accessCardDto = memberService.getAccessCardData(memberId); + + return ApiResponse.success(accessCardDto, HttpStatus.OK); + } + +} diff --git a/src/main/java/com/livable/server/home/dto/HomeResponse.java b/src/main/java/com/livable/server/home/dto/HomeResponse.java new file mode 100644 index 00000000..e1d47aa9 --- /dev/null +++ b/src/main/java/com/livable/server/home/dto/HomeResponse.java @@ -0,0 +1,55 @@ +package com.livable.server.home.dto; + +import com.livable.server.member.dto.AccessCardProjection; +import com.livable.server.member.dto.BuildingInfoProjection; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class HomeResponse { + + @Getter + @Builder + public static class BuildingInfoDto { + + private Long buildingId; + private String buildingName; + private Boolean hasCafeteria; + + public static BuildingInfoDto from(BuildingInfoProjection buildingInfoProjection) { + return BuildingInfoDto.builder() + .buildingId(buildingInfoProjection.getBuildingId()) + .buildingName(buildingInfoProjection.getBuildingName()) + .hasCafeteria(buildingInfoProjection.getHasCafeteria()) + .build(); + } + + } + + @Getter + @Builder + public static class AccessCardDto { + + private String buildingName; + private String employeeNumber; + private String companyName; + private String floor; + private String roomNumber; + private String employeeName; + + public static AccessCardDto from(AccessCardProjection accessCardProjection) { + return AccessCardDto + .builder() + .buildingName(accessCardProjection.getBuildingName()) + .employeeNumber(accessCardProjection.getEmployeeNumber()) + .companyName(accessCardProjection.getCompanyName()) + .floor(accessCardProjection.getFloor()) + .roomNumber(accessCardProjection.getRoomNumber()) + .employeeName(accessCardProjection.getEmployeeName()) + .build(); + } + } + +} diff --git a/src/main/java/com/livable/server/invitation/controller/InvitationController.java b/src/main/java/com/livable/server/invitation/controller/InvitationController.java new file mode 100644 index 00000000..ec43d010 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/controller/InvitationController.java @@ -0,0 +1,92 @@ +package com.livable.server.invitation.controller; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.response.ApiResponse.Success; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.invitation.dto.InvitationRequest; +import com.livable.server.invitation.dto.InvitationResponse; +import com.livable.server.invitation.service.InvitationService; +import com.livable.server.member.domain.MemberErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +@RequestMapping("/api/invitation") +@RestController +public class InvitationController { + + private final InvitationService invitationService; + + @GetMapping("/places/available") + public ResponseEntity> getAvailablePlaces(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + return invitationService.getAvailablePlaces(memberId); + } + + @PostMapping + public ResponseEntity createInvitation( + @Valid @RequestBody InvitationRequest.CreateDTO dto, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + return invitationService.createInvitation(dto, memberId); + } + + @GetMapping + public ResponseEntity>>> getInvitations(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + return invitationService.getInvitations(memberId); + } + + @GetMapping("/{invitationId}") + public ResponseEntity> getInvitation( + @PathVariable Long invitationId, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + return invitationService.getInvitation(invitationId, memberId); + } + + @DeleteMapping("/{invitationId}") + public ResponseEntity deleteInvitation(@PathVariable Long invitationId, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + return invitationService.deleteInvitation(invitationId, memberId); + } + + @PatchMapping("/{invitationId}") + public ResponseEntity updateInvitation( + @PathVariable Long invitationId, @Valid @RequestBody InvitationRequest.UpdateDTO dto, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + return invitationService.updateInvitation(invitationId, dto, memberId); + } + +} diff --git a/src/main/java/com/livable/server/invitation/controller/InvitationValidationController.java b/src/main/java/com/livable/server/invitation/controller/InvitationValidationController.java new file mode 100644 index 00000000..b004fa76 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/controller/InvitationValidationController.java @@ -0,0 +1,32 @@ +package com.livable.server.invitation.controller; + +import com.livable.server.invitation.service.InvitationValidationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpServletResponse; + +@RequiredArgsConstructor +@Controller +public class InvitationValidationController { + + private static final String visitationPageUrl = "https://livable.vercel.app/invitation/view"; + + private final InvitationValidationService invitationValidationService; + + @GetMapping("api/invitation/callback") + public String validateVisitor(@RequestParam String token, HttpServletResponse response) { + + invitationValidationService.validateVisitor(token); + + response.setHeader("Authorization", createBearerToken(token)); + + return "redirect:" + visitationPageUrl; + } + + private String createBearerToken(String token) { + return "Bearer " + token; + } +} diff --git a/src/main/java/com/livable/server/invitation/domain/InvitationErrorCode.java b/src/main/java/com/livable/server/invitation/domain/InvitationErrorCode.java new file mode 100644 index 00000000..d58d39e4 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/domain/InvitationErrorCode.java @@ -0,0 +1,36 @@ +package com.livable.server.invitation.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum InvitationErrorCode implements ErrorCode { + MEMBER_NOT_EXIST(HttpStatus.BAD_REQUEST, "존재하지 않는 회원 정보입니다."), + INVITATION_NOT_EXIST(HttpStatus.BAD_REQUEST, "존재하지 않는 초대장 정보입니다."), + INVALID_INTERVIEW_MAXIMUM_NUMBER(HttpStatus.BAD_REQUEST, "면접 초대 가능 인원수는 1명입니다."), + INVALID_INVITATION_MAXIMUM_NUMBER(HttpStatus.BAD_REQUEST, "한 초대장에 등록할 수 있는 최대 방문자는 30명입니다."), + INVALID_DATE(HttpStatus.BAD_REQUEST, "종료 날짜가 시작 날짜보다 과거일 수 없습니다."), + INVALID_TIME(HttpStatus.BAD_REQUEST, "종료 시간이 시작 시간보다 과거일 수 없습니다."), + INVALID_TIME_UNIT(HttpStatus.BAD_REQUEST, "시간의 분 단위는 0분 또는 30분이어야 합니다."), + INVALID_RESERVATION_COUNT(HttpStatus.BAD_REQUEST, "해당 날짜 또는 시간에 예약된 장소 정보가 없습니다."), + INVALID_INVITATION_OWNER(HttpStatus.FORBIDDEN, "초대장을 작성한 회원만 접근이 가능합니다."), + INVALID_DELETE_DATE(HttpStatus.BAD_REQUEST, "초대장 삭제는 방문일 기준 전날까지만 가능합니다."), + CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION(HttpStatus.BAD_REQUEST, "초대장의 예약된 장소는 변경할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/livable/server/invitation/domain/InvitationPurpose.java b/src/main/java/com/livable/server/invitation/domain/InvitationPurpose.java new file mode 100644 index 00000000..a5d2c227 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/domain/InvitationPurpose.java @@ -0,0 +1,19 @@ +package com.livable.server.invitation.domain; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum InvitationPurpose { + + MEETING("meeting"), + INTERVIEW("interview"), + PERIOD_WORK("fixedTermWork"), + SEMINAR("seminar"), + AFTER_SERVICE("as"), + ETC("etc"); + + private final String value; +} diff --git a/src/main/java/com/livable/server/invitation/domain/InvitationValidationMessage.java b/src/main/java/com/livable/server/invitation/domain/InvitationValidationMessage.java new file mode 100644 index 00000000..05d62115 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/domain/InvitationValidationMessage.java @@ -0,0 +1,11 @@ +package com.livable.server.invitation.domain; + +public interface InvitationValidationMessage { + String REQUIRED_FUTURE_DATE = "선택된 시간이 현재보다 과거일 수 없습니다."; + String REQUIRED_VISITOR_COUNT = "방문자는 최소 1명 이상 30명 이하여야 합니다. (면접은 1명)"; + String NOT_NULL = "값이 Null 일수 없습니다."; + String VISITOR_NAME_MIN_SIZE = "방문자 이름은 2글자 이상이어야 합니다."; + String VISITOR_NAME_FORMAT = "방문자 이름은 한글만 입력해야 합니다."; + String VISITOR_CONTACT_MIN_SIZE = "방문자 전화번호는 10글자 이상이어야 합니다."; + String VISITOR_CONTACT_FORMAT = "방문자 전화번호는 숫자만 입력해야 합니다."; +} diff --git a/src/main/java/com/livable/server/invitation/dto/InvitationDetailTimeDto.java b/src/main/java/com/livable/server/invitation/dto/InvitationDetailTimeDto.java new file mode 100644 index 00000000..dcc04fe3 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/dto/InvitationDetailTimeDto.java @@ -0,0 +1,16 @@ +package com.livable.server.invitation.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +public interface InvitationDetailTimeDto { + + LocalDate getStartDate(); + + LocalDate getEndDate(); + + LocalTime getStartTime(); + + LocalTime getEndTime(); +} diff --git a/src/main/java/com/livable/server/invitation/dto/InvitationRequest.java b/src/main/java/com/livable/server/invitation/dto/InvitationRequest.java new file mode 100644 index 00000000..c32dec98 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/dto/InvitationRequest.java @@ -0,0 +1,140 @@ +package com.livable.server.invitation.dto; + +import com.livable.server.entity.Invitation; +import com.livable.server.entity.Member; +import com.livable.server.entity.Visitor; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.Valid; +import javax.validation.constraints.FutureOrPresent; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; + +import static com.livable.server.invitation.domain.InvitationValidationMessage.*; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class InvitationRequest { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateDTO { + + @NotNull(message = NOT_NULL) + private String purpose; + + private Long commonPlaceId; + + @NotNull(message = NOT_NULL) + private String officeName; + + private String description; + + @NotNull(message = NOT_NULL) + @FutureOrPresent(message = REQUIRED_FUTURE_DATE) + private LocalDateTime startDate; + + @NotNull(message = NOT_NULL) + @FutureOrPresent(message = REQUIRED_FUTURE_DATE) + private LocalDateTime endDate; + + @Valid + @Size(min = 1, max = 30, message = REQUIRED_VISITOR_COUNT) + private List visitors; + + public Invitation toEntity(Member member) { + return Invitation.builder() + .member(member) + .purpose(purpose) + .officeName(officeName) + .description(description) + .startDate(startDate.toLocalDate()) + .endDate(endDate.toLocalDate()) + .startTime(startDate.toLocalTime()) + .endTime(endDate.toLocalTime()) + .build(); + } + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class VisitorCreateDTO { + + @Pattern(regexp = "^[가-힣]*$", message = VISITOR_NAME_FORMAT) + @Size(min = 2, message = VISITOR_NAME_MIN_SIZE) + @NotNull(message = NOT_NULL) + private String name; + + @Pattern(regexp = "^[0-9]*$", message = VISITOR_CONTACT_FORMAT) + @Size(min = 10, message = VISITOR_CONTACT_MIN_SIZE) + @NotNull(message = NOT_NULL) + private String contact; + + public Visitor toEntity(Invitation invitation) { + return Visitor.builder() + .invitation(invitation) + .name(name) + .contact(contact) + .firstVisitedTime(null) + .build(); + } + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UpdateDTO { + private Long commonPlaceId; + private String description; + + @NotNull(message = NOT_NULL) + @FutureOrPresent(message = REQUIRED_FUTURE_DATE) + private LocalDateTime startDate; + + @NotNull(message = NOT_NULL) + @FutureOrPresent(message = REQUIRED_FUTURE_DATE) + private LocalDateTime endDate; + + @Valid + @NotNull(message = NOT_NULL) + private List visitors; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class VisitorForUpdateDTO { + + @Pattern(regexp = "^[가-힣]*$", message = VISITOR_NAME_FORMAT) + @Size(min = 2, message = VISITOR_NAME_MIN_SIZE) + @NotNull(message = NOT_NULL) + private String name; + + @Pattern(regexp = "^[0-9]*$", message = VISITOR_CONTACT_FORMAT) + @Size(min = 10, message = VISITOR_CONTACT_MIN_SIZE) + @NotNull(message = NOT_NULL) + private String contact; + + public Visitor toEntity(Invitation invitation) { + return Visitor.builder() + .invitation(invitation) + .name(name) + .contact(contact) + .firstVisitedTime(null) + .build(); + } + } + +} diff --git a/src/main/java/com/livable/server/invitation/dto/InvitationResponse.java b/src/main/java/com/livable/server/invitation/dto/InvitationResponse.java new file mode 100644 index 00000000..4d55184b --- /dev/null +++ b/src/main/java/com/livable/server/invitation/dto/InvitationResponse.java @@ -0,0 +1,138 @@ +package com.livable.server.invitation.dto; + +import com.livable.server.entity.Office; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class InvitationResponse { + + + @Getter + @Builder + public static class AvailablePlacesDTO { + private List offices; + private List commonPlaces; + } + + @Getter + @Builder + public static class OfficeDTO { + private String officeName; + + public static OfficeDTO from(Office office) { + return new OfficeDTO(getFormattedPlaceName( + office.getName(), + office.getFloor(), + office.getRoomNumber() + )); + } + } + + @Getter + @Builder + public static class CommonPlaceDTO { + private Long commonPlaceId; + private String commonPlaceName; + + public static CommonPlaceDTO from(ReservationDTO reservationDTO) { + return new CommonPlaceDTO( + reservationDTO.getCommonPlaceId(), + getFormattedPlaceName( + reservationDTO.getCommonPlaceName(), + reservationDTO.getCommonPlaceFloor(), + reservationDTO.getCommonPlaceRoomNumber() + ) + ); + } + } + + private static String getFormattedPlaceName(String name, String floor, String roomNumber) { + return String.format("%s (%s층 %s호)", name, floor, roomNumber); + } + + @Getter + @Setter + public static class ReservationDTO { + private Long commonPlaceId; + private String commonPlaceFloor; + private String commonPlaceRoomNumber; + private String commonPlaceName; + + public ReservationDTO( + Long commonPlaceId, + String commonPlaceFloor, + String commonPlaceRoomNumber, + String commonPlaceName + ) { + this.commonPlaceId = commonPlaceId; + this.commonPlaceFloor = commonPlaceFloor; + this.commonPlaceRoomNumber = commonPlaceRoomNumber; + this.commonPlaceName = commonPlaceName; + } + } + + @Getter + @AllArgsConstructor + public static class ListDTO { + private Long invitationId; + private String visitorName; + private Long visitorCount; + private String purpose; + private String officeName; + private LocalDate startDate; + private LocalTime startTime; + private LocalTime endTime; + } + + @Getter + @Builder + @AllArgsConstructor + public static class DetailDTO { + private Long commonPlaceId; + private String officeName; + private String purpose; + private String description; + private LocalDate startDate; + private LocalDate endDate; + private LocalTime startTime; + private LocalTime endTime; + private List visitors; + + public DetailDTO( + Long commonPlaceId, + String officeName, + String purpose, + String description, + LocalDate startDate, + LocalDate endDate, + LocalTime startTime, + LocalTime endTime + ) { + this.commonPlaceId = commonPlaceId; + this.officeName = officeName; + this.purpose = purpose; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + this.startTime = startTime; + this.endTime = endTime; + } + + public void setVisitors(List visitors) { + this.visitors = visitors; + } + } + + @Getter + @AllArgsConstructor + public static class VisitorForDetailDTO { + private Long visitorId; + private String name; + private String contact; + } + +} diff --git a/src/main/java/com/livable/server/invitation/repository/InvitationQueryRepository.java b/src/main/java/com/livable/server/invitation/repository/InvitationQueryRepository.java new file mode 100644 index 00000000..aed6fd79 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/repository/InvitationQueryRepository.java @@ -0,0 +1,11 @@ +package com.livable.server.invitation.repository; + +import com.livable.server.invitation.dto.InvitationResponse; + +import java.util.List; + +public interface InvitationQueryRepository { + List findInvitationsByMemberId(Long memberId); + InvitationResponse.DetailDTO findInvitationAndVisitorsByInvitationId(Long invitationId); + Long getCommonPlaceIdByInvitationId(Long invitationId); +} diff --git a/src/main/java/com/livable/server/invitation/repository/InvitationQueryRepositoryImpl.java b/src/main/java/com/livable/server/invitation/repository/InvitationQueryRepositoryImpl.java new file mode 100644 index 00000000..77954fe3 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/repository/InvitationQueryRepositoryImpl.java @@ -0,0 +1,107 @@ +package com.livable.server.invitation.repository; + +import com.livable.server.entity.QVisitor; +import com.livable.server.invitation.dto.InvitationResponse; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +import static com.livable.server.entity.QInvitation.invitation; +import static com.livable.server.entity.QInvitationReservationMap.invitationReservationMap; +import static com.livable.server.entity.QReservation.reservation; +import static com.livable.server.entity.QVisitor.visitor; + +@RequiredArgsConstructor +public class InvitationQueryRepositoryImpl implements InvitationQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findInvitationsByMemberId(Long memberId) { + QVisitor subVisitorForMaxId = new QVisitor("subVisitor"); + QVisitor subVisitor = new QVisitor("subVisitor"); + + return queryFactory + .select(Projections.constructor(InvitationResponse.ListDTO.class, + visitor.invitation.id, + JPAExpressions + .select(subVisitor.name) + .from(subVisitor) + .where(subVisitor.id.eq( + JPAExpressions + .select(subVisitorForMaxId.id.min()) + .from(subVisitorForMaxId) + .where(subVisitorForMaxId.invitation.id.eq(visitor.invitation.id)) + )), + visitor.invitation.id.count(), + invitation.purpose, + invitation.officeName, + invitation.startDate, + invitation.startTime, + invitation.endTime + )) + .from(visitor) + .innerJoin(invitation) + .on( + visitor.invitation.id.eq(invitation.id), + invitation.member.id.eq(memberId), + invitation.startDate.goe(LocalDate.now()) + ) + .groupBy(visitor.invitation.id) + .orderBy(invitation.startDate.asc(), invitation.startTime.asc()) + .fetch(); + } + + @Override + public InvitationResponse.DetailDTO findInvitationAndVisitorsByInvitationId(Long invitationId) { + InvitationResponse.DetailDTO invitationDetail = queryFactory + .select(Projections.constructor(InvitationResponse.DetailDTO.class, + reservation.commonPlace.id, + invitation.officeName, + invitation.purpose, + invitation.description, + invitation.startDate, + invitation.endDate, + invitation.startTime, + invitation.endTime + )) + .from(invitation) + .leftJoin(invitationReservationMap) + .on(invitationReservationMap.invitation.id.eq(invitation.id)) + .leftJoin(reservation) + .on(reservation.id.eq(invitationReservationMap.reservation.id)) + .where(invitation.id.eq(invitationId)) + .fetchFirst(); + + List visitors = queryFactory + .select(Projections.constructor(InvitationResponse.VisitorForDetailDTO.class, + visitor.id, + visitor.name, + visitor.contact + )) + .from(visitor) + .where(visitor.invitation.id.eq(invitationId)) + .fetch(); + + invitationDetail.setVisitors(visitors); + + return invitationDetail; + } + + @Override + public Long getCommonPlaceIdByInvitationId(Long invitationId) { + return queryFactory + .select(reservation.commonPlace.id) + .from(invitation) + .leftJoin(invitationReservationMap) + .on(invitationReservationMap.invitation.id.eq(invitation.id)) + .leftJoin(reservation) + .on(reservation.id.eq(invitationReservationMap.reservation.id)) + .where(invitation.id.eq(invitationId)) + .fetchFirst(); + } +} diff --git a/src/main/java/com/livable/server/invitation/repository/InvitationRepository.java b/src/main/java/com/livable/server/invitation/repository/InvitationRepository.java new file mode 100644 index 00000000..ff3339af --- /dev/null +++ b/src/main/java/com/livable/server/invitation/repository/InvitationRepository.java @@ -0,0 +1,22 @@ +package com.livable.server.invitation.repository; + +import com.livable.server.entity.Invitation; +import com.livable.server.invitation.dto.InvitationDetailTimeDto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface InvitationRepository extends JpaRepository, InvitationQueryRepository { + + @Query("select i.startTime as startTime, i.endTime as endTime, i.startDate as startDate, i.endDate as endDate" + + " from Visitor v" + + " join fetch Invitation i" + + " on v.invitation = i" + + " where v.id = :visitorId") + Optional findInvitationDetailTimeByVisitorId(@Param("visitorId") Long visitorId); + + @Query("select count(i) from Invitation i where i.id = :invitationId and i.member.id = :memberId") + Long countByIdAndMemberId(@Param("invitationId") Long invitationId, @Param("memberId") Long memberId); +} diff --git a/src/main/java/com/livable/server/invitation/repository/InvitationReservationMapRepository.java b/src/main/java/com/livable/server/invitation/repository/InvitationReservationMapRepository.java new file mode 100644 index 00000000..4504ac11 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/repository/InvitationReservationMapRepository.java @@ -0,0 +1,18 @@ +package com.livable.server.invitation.repository; + +import com.livable.server.entity.InvitationReservationMap; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface InvitationReservationMapRepository extends JpaRepository { + + @Modifying + @Query("delete from InvitationReservationMap irm where irm.invitation.id = :invitationId") + void deleteAllByInvitationId(Long invitationId); + + @Query("select ir.reservation.id from InvitationReservationMap ir") + List findAllReservationId(); +} diff --git a/src/main/java/com/livable/server/invitation/repository/OfficeRepository.java b/src/main/java/com/livable/server/invitation/repository/OfficeRepository.java new file mode 100644 index 00000000..ac4a8304 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/repository/OfficeRepository.java @@ -0,0 +1,11 @@ +package com.livable.server.invitation.repository; + +import com.livable.server.entity.Office; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OfficeRepository extends JpaRepository { + + List findAllByCompanyId(Long companyId); +} diff --git a/src/main/java/com/livable/server/invitation/service/InvitationService.java b/src/main/java/com/livable/server/invitation/service/InvitationService.java new file mode 100644 index 00000000..510d603c --- /dev/null +++ b/src/main/java/com/livable/server/invitation/service/InvitationService.java @@ -0,0 +1,373 @@ +package com.livable.server.invitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.response.ApiResponse.Success; +import com.livable.server.entity.*; +import com.livable.server.invitation.domain.InvitationErrorCode; +import com.livable.server.invitation.domain.InvitationPurpose; +import com.livable.server.invitation.dto.InvitationDetailTimeDto; +import com.livable.server.invitation.dto.InvitationRequest; +import com.livable.server.invitation.dto.InvitationResponse; +import com.livable.server.invitation.repository.InvitationRepository; +import com.livable.server.invitation.repository.InvitationReservationMapRepository; +import com.livable.server.invitation.repository.OfficeRepository; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.reservation.repository.ReservationRepository; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.repository.ParkingLogRepository; +import com.livable.server.visitation.repository.VisitorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class InvitationService { + private static final int INTERVIEW_MAXIMUM_COUNT = 1; + private static final int INVITATION_MAXIMUM_COUNT = 30; + + private final MemberRepository memberRepository; + private final OfficeRepository officeRepository; + private final InvitationRepository invitationRepository; + private final ReservationRepository reservationRepository; + private final InvitationReservationMapRepository invitationReservationMapRepository; + private final VisitorRepository visitorRepository; + private final ParkingLogRepository parkingLogRepository; + + public VisitationResponse.InvitationTimeDto findInvitationTime(Long visitorId) { + InvitationDetailTimeDto invitationDetailTimeDto = invitationRepository.findInvitationDetailTimeByVisitorId(visitorId) + .orElseThrow(() -> new GlobalRuntimeException(VisitationErrorCode.NOT_FOUND)); + + return VisitationResponse.InvitationTimeDto.from(invitationDetailTimeDto); + } + + @Transactional(readOnly = true) + public ResponseEntity> getAvailablePlaces(Long memberId) { + Long companyId = getCompanyIdByMemberId(memberId); + + List officeEntities = officeRepository.findAllByCompanyId(companyId); + + List reservations = reservationRepository + .findReservationsByCompanyId(companyId); + + List offices = officeEntities.stream() + .map(InvitationResponse.OfficeDTO::from).collect(Collectors.toList()); + + List commonPlaces = reservations.stream() + .map(InvitationResponse.CommonPlaceDTO::from).collect(Collectors.toList()); + + InvitationResponse.AvailablePlacesDTO responseBody = InvitationResponse.AvailablePlacesDTO.builder() + .offices(offices) + .commonPlaces(commonPlaces) + .build(); + + return ApiResponse.success(responseBody, HttpStatus.OK); + } + + private Long getCompanyIdByMemberId(Long memberId) { + Member member = checkExistMemberById(memberId); + + return member.getCompany().getId(); + } + + @Transactional + public ResponseEntity createInvitation(InvitationRequest.CreateDTO dto, Long memberId) { + checkInterviewVisitorCount(dto); + + Member member = checkExistMemberById(memberId); + Invitation invitation = createInvitation(dto, member); + createVisitors(dto.getVisitors(), invitation); + reserveCommonPlaces(dto, invitation); + + return ApiResponse.success(HttpStatus.CREATED); + } + + private void checkInterviewVisitorCount(InvitationRequest.CreateDTO dto) { + if (dto.getPurpose().equals(InvitationPurpose.INTERVIEW.getValue()) + && dto.getVisitors().size() > INTERVIEW_MAXIMUM_COUNT) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_INTERVIEW_MAXIMUM_NUMBER); + } + } + + private Member checkExistMemberById(Long memberId) { + Optional memberOptional = memberRepository.findById(memberId); + + return memberOptional.orElseThrow(() -> new GlobalRuntimeException(InvitationErrorCode.MEMBER_NOT_EXIST)); + } + + private Invitation createInvitation(InvitationRequest.CreateDTO dto, Member member) { + Invitation invitation = dto.toEntity(member); + + return invitationRepository.save(invitation); + } + + private void createVisitors(List visitorCreateDTOS, Invitation invitation) { + for (InvitationRequest.VisitorCreateDTO visitorCreateDTO : visitorCreateDTOS) { + Visitor visitor = visitorCreateDTO.toEntity(invitation); + visitorRepository.save(visitor); + } + } + + private void reserveCommonPlaces(InvitationRequest.CreateDTO dto, Invitation invitation) { + LocalDateTime startDateTime = dto.getStartDate(); + LocalDateTime endDateTime = dto.getEndDate(); + checkDateTimeValidate(startDateTime, endDateTime); + + if (isReservedCommonPlace(dto.getCommonPlaceId())) { + int expectedReservationCount = getExpectedReservationCount(startDateTime, endDateTime); + List reservations = reservationRepository + .findReservationsByCommonPlaceIdAndStartDateAndEndDate(dto.getCommonPlaceId(), startDateTime, endDateTime); + + checkReservationCount(reservations, expectedReservationCount); + createInvitationReservationMap(reservations, invitation); + } + } + + private void checkDateTimeValidate(LocalDateTime startDateTime, LocalDateTime endDateTime) { + if (endDateTime.toLocalDate().isBefore(startDateTime.toLocalDate())) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_DATE); + } + if (!endDateTime.toLocalTime().isAfter(startDateTime.toLocalTime())) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_TIME); + } + checkTimeUnitValidation(startDateTime.toLocalTime(), endDateTime.toLocalTime()); + } + + private void checkTimeUnitValidation(LocalTime startTime, LocalTime endTime) { + int startMinute = startTime.getMinute(); + int endMinute = endTime.getMinute(); + + if ((startMinute % 30 != 0) || (endMinute % 30 != 0)) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_TIME_UNIT); + } + } + + private boolean isReservedCommonPlace(Long commonPlaceId) { + return commonPlaceId != null; + } + + /* 입력된 시간 범위 내에서 반드시 존재해야 하는 예약 정보 개수를 반환 */ + private int getExpectedReservationCount(LocalDateTime startDateTime, LocalDateTime endDateTime) { + LocalDate startDate = startDateTime.toLocalDate(); + LocalDate endDate = endDateTime.toLocalDate(); + LocalTime startTime = startDateTime.toLocalTime(); + LocalTime endTime = endDateTime.toLocalTime(); + + int dayCount = (int) Duration.between(startDate.atStartOfDay(), endDate.atStartOfDay()).toDays() + 1; + int timeCount = (endTime.toSecondOfDay() - startTime.toSecondOfDay()) / 1800; + + return dayCount * timeCount; + } + + /* 입력된 시작, 종료 날짜에 대한 예약 정보 개수가 예상한 값과 맞는지 확인 */ + private void checkReservationCount(List reservations, int count) { + if (reservations.size() != count) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_RESERVATION_COUNT); + } + } + + private void createInvitationReservationMap(List reservations, Invitation invitation) { + for (Reservation reservation : reservations) { + InvitationReservationMap invitationReservationMap = InvitationReservationMap.builder() + .invitation(invitation) + .reservation(reservation) + .build(); + + invitationReservationMapRepository.save(invitationReservationMap); + } + } + + @Transactional(readOnly = true) + public ResponseEntity>>> getInvitations(Long memberId) { + checkExistMemberById(memberId); + List invitationDTOs = invitationRepository.findInvitationsByMemberId(memberId); + Map> responseBody = getInvitationsGroupByLocalDate(invitationDTOs); + + return ApiResponse.success(responseBody, HttpStatus.OK); + } + + private Map> getInvitationsGroupByLocalDate( + List invitationDTOS) { + + Map> invitationsGroupByLocalDate + = new TreeMap<>(LocalDate::compareTo); + + for (InvitationResponse.ListDTO invitation : invitationDTOS) { + LocalDate date = invitation.getStartDate(); + + if (!invitationsGroupByLocalDate.containsKey(date)) { + invitationsGroupByLocalDate.put(date, new ArrayList<>()); + } + invitationsGroupByLocalDate.get(date).add(invitation); + } + + return invitationsGroupByLocalDate; + } + + @Transactional(readOnly = true) + public ResponseEntity> getInvitation(Long invitationId, Long memberId) { + checkExistMemberById(memberId); + checkInvitationOwner(invitationId, memberId); + + InvitationResponse.DetailDTO invitationDTO + = invitationRepository.findInvitationAndVisitorsByInvitationId(invitationId); + + return ApiResponse.success(invitationDTO, HttpStatus.OK); + } + + private void checkInvitationOwner(Long invitationId, Long memberId) { + + Optional invitationOptional = invitationRepository.findById(invitationId); + Invitation invitation = invitationOptional + .orElseThrow(() -> new GlobalRuntimeException(InvitationErrorCode.INVITATION_NOT_EXIST)); + + if (!invitation.getMember().getId().equals(memberId)) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_INVITATION_OWNER); + } + } + + @Transactional + public ResponseEntity deleteInvitation(Long invitationId, Long memberId) { + checkExistMemberById(memberId); + checkInvitationOwner(invitationId, memberId); + + // Step 1. 초대장 가져옴 + Optional invitationOptional = invitationRepository.findById(invitationId); + Invitation invitation = invitationOptional + .orElseThrow(() -> new GlobalRuntimeException(InvitationErrorCode.INVITATION_NOT_EXIST)); + + // Step 2. 초대장 방문날짜 확인 + checkInvitationStartDate(invitation); + + // Step 3. 예약된 장소에 대한 예약 정보 제거 + deleteReservationsByInvitation(invitation); + + // Step 4. 초대장에 등록된 방문자 데이터 + 주차 등록 데이터 삭제 + deleteVisitorsAndParkingLogByInvitation(invitation); + + // Step 5. 초대장 삭제 + invitationRepository.delete(invitation); + + return ApiResponse.success(HttpStatus.OK); + } + + private void checkInvitationStartDate(Invitation invitation) { + if (invitation.getStartDate().isBefore(LocalDate.now())) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_DELETE_DATE); + } + } + + private void deleteReservationsByInvitation(Invitation invitation) { + invitationReservationMapRepository.deleteAllByInvitationId(invitation.getId()); + } + + private void deleteVisitorsAndParkingLogByInvitation(Invitation invitation) { + List visitors = visitorRepository.findVisitorsByInvitation(invitation); + List visitorsIds = visitors.stream().map(Visitor::getId).collect(Collectors.toList()); + + parkingLogRepository.deleteByVisitorIdsIn(visitorsIds); + visitorRepository.deleteByIdsIn(visitorsIds); + } + + @Transactional + public ResponseEntity updateInvitation(Long invitationId, InvitationRequest.UpdateDTO dto, Long memberId) { + checkExistMemberById(memberId); + checkInvitationOwner(invitationId, memberId); + + Optional invitationOptional = invitationRepository.findById(invitationId); + Invitation invitation = invitationOptional + .orElseThrow(() -> new GlobalRuntimeException(InvitationErrorCode.INVITATION_NOT_EXIST)); + + checkInvitationStartDate(invitation); + checkModifiedCommonPlaceId(invitation, dto); + + boolean shouldSendToAlreadyVisitor = false; + boolean shouldSendToAddedVisitor = checkAddedVisitorsCount(invitation, dto); + + if (isModifiedInvitationDateTime(invitation, dto)) { + shouldSendToAlreadyVisitor = true; + if (isReservedCommonPlace(dto.getCommonPlaceId())) { + + invitationReservationMapRepository.deleteAllByInvitationId(invitation.getId()); + reserveNewCommonPlaces(dto, invitation); + } + } + + invitation.updateDateTime(dto.getStartDate(), dto.getEndDate()); + invitation.updateDescription(dto.getDescription()); + + if (shouldSendToAlreadyVisitor) { + List currentVisitors = visitorRepository.findVisitorsByInvitation(invitation); + + // TODO: 기존 등록되어 있던 방문자들에게 알림톡을 다시 보내는 로직 추가 + } + + if (shouldSendToAddedVisitor) { + List visitors = dto.getVisitors().stream() + .map(visitor -> visitor.toEntity(invitation)).collect(Collectors.toList()); + + visitorRepository.saveAll(visitors); + + // TODO: 새로 등록된 방문자들에게 알림톡을 다시 보내는 로직 추가 + } + + return ApiResponse.success(HttpStatus.OK); + } + + private boolean checkAddedVisitorsCount(Invitation invitation, InvitationRequest.UpdateDTO dto) { + long addedCount = dto.getVisitors().size(); + + if (addedCount != 0L && invitation.getPurpose().equals(InvitationPurpose.INTERVIEW.getValue())) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_INTERVIEW_MAXIMUM_NUMBER); + } + + long alreadyCount = visitorRepository.countByInvitation(invitation); + + if (alreadyCount + addedCount > INVITATION_MAXIMUM_COUNT) { + throw new GlobalRuntimeException(InvitationErrorCode.INVALID_INVITATION_MAXIMUM_NUMBER); + } + + return addedCount != 0; + } + + private boolean isModifiedInvitationDateTime(Invitation invitation, InvitationRequest.UpdateDTO dto) { + return !LocalDateTime.of(invitation.getStartDate(), invitation.getStartTime()).isEqual(dto.getStartDate()) + || !LocalDateTime.of(invitation.getEndDate(), invitation.getEndTime()).isEqual(dto.getEndDate()); + } + + private void checkModifiedCommonPlaceId(Invitation invitation, InvitationRequest.UpdateDTO dto) { + Long currentCommonPlaceId = invitationRepository.getCommonPlaceIdByInvitationId(invitation.getId()); + + if (currentCommonPlaceId != null && !currentCommonPlaceId.equals(dto.getCommonPlaceId())) { + throw new GlobalRuntimeException(InvitationErrorCode.CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION); + } + + if (currentCommonPlaceId == null && dto.getCommonPlaceId() != null) { + throw new GlobalRuntimeException(InvitationErrorCode.CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION); + } + } + + private void reserveNewCommonPlaces(InvitationRequest.UpdateDTO dto, Invitation invitation) { + LocalDateTime startDateTime = dto.getStartDate(); + LocalDateTime endDateTime = dto.getEndDate(); + checkDateTimeValidate(startDateTime, endDateTime); + + int expectedReservationCount = getExpectedReservationCount(startDateTime, endDateTime); + List reservations = reservationRepository + .findReservationsByCommonPlaceIdAndStartDateAndEndDate(dto.getCommonPlaceId(), startDateTime, endDateTime); + + checkReservationCount(reservations, expectedReservationCount); + createInvitationReservationMap(reservations, invitation); + } +} diff --git a/src/main/java/com/livable/server/invitation/service/InvitationValidationService.java b/src/main/java/com/livable/server/invitation/service/InvitationValidationService.java new file mode 100644 index 00000000..cf176e00 --- /dev/null +++ b/src/main/java/com/livable/server/invitation/service/InvitationValidationService.java @@ -0,0 +1,25 @@ +package com.livable.server.invitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.member.domain.MemberErrorCode; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class InvitationValidationService { + + private final JwtTokenProvider tokenProvider; + + public void validateVisitor(String token) { + Claims claims = tokenProvider.parseClaims(token); + + String actorType = claims.get("actorType", String.class); + if (!actorType.equals(ActorType.VISITOR.name())) { + throw new GlobalRuntimeException(MemberErrorCode.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/com/livable/server/member/controller/MemberController.java b/src/main/java/com/livable/server/member/controller/MemberController.java new file mode 100644 index 00000000..74769bd3 --- /dev/null +++ b/src/main/java/com/livable/server/member/controller/MemberController.java @@ -0,0 +1,31 @@ +package com.livable.server.member.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.member.dto.MemberResponse; +import com.livable.server.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/api/members") + public ResponseEntity> myPage(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + MemberResponse.MyPageDTO myPageDTO = memberService.getMyPageData(memberId); + + return ApiResponse.success(myPageDTO, HttpStatus.OK); + } +} diff --git a/src/main/java/com/livable/server/member/domain/MemberErrorCode.java b/src/main/java/com/livable/server/member/domain/MemberErrorCode.java new file mode 100644 index 00000000..27b7d380 --- /dev/null +++ b/src/main/java/com/livable/server/member/domain/MemberErrorCode.java @@ -0,0 +1,26 @@ +package com.livable.server.member.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum MemberErrorCode implements ErrorCode { + + MEMBER_NOT_EXIST(HttpStatus.BAD_REQUEST, "존재하지 않는 회원 정보입니다."), + + BUILDING_INFO_NOT_EXIST(HttpStatus.BAD_REQUEST, "해당 회원의 빌딩 정보가 존재하지 않습니다."), + + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 토큰 정보입니다."), + + INVALID_ACTOR_TYPE(HttpStatus.BAD_REQUEST, "존재하지 않는 유형의 사용자입니다."), + + RETRIEVE_ACCESSCARD_FAILED(HttpStatus.BAD_REQUEST, "출입 카드 정보를 조회 할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/livable/server/member/dto/AccessCardProjection.java b/src/main/java/com/livable/server/member/dto/AccessCardProjection.java new file mode 100644 index 00000000..fcd86984 --- /dev/null +++ b/src/main/java/com/livable/server/member/dto/AccessCardProjection.java @@ -0,0 +1,17 @@ +package com.livable.server.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AccessCardProjection { + + String buildingName; + String employeeNumber; + String companyName; + String floor; + String roomNumber; + String employeeName; + +} diff --git a/src/main/java/com/livable/server/member/dto/BuildingInfoProjection.java b/src/main/java/com/livable/server/member/dto/BuildingInfoProjection.java new file mode 100644 index 00000000..517abeb2 --- /dev/null +++ b/src/main/java/com/livable/server/member/dto/BuildingInfoProjection.java @@ -0,0 +1,14 @@ +package com.livable.server.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class BuildingInfoProjection { + + Long buildingId; + String buildingName; + Boolean hasCafeteria; + +} diff --git a/src/main/java/com/livable/server/member/dto/MemberResponse.java b/src/main/java/com/livable/server/member/dto/MemberResponse.java new file mode 100644 index 00000000..ff1e757d --- /dev/null +++ b/src/main/java/com/livable/server/member/dto/MemberResponse.java @@ -0,0 +1,24 @@ +package com.livable.server.member.dto; + +import lombok.*; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberResponse { + + @Getter + @Builder + public static class MyPageDTO { + + String memberName; + String companyName; + Integer pointValance; + + public static MyPageDTO from(MyPageProjection myPageProjection) { + return MyPageDTO.builder() + .memberName(myPageProjection.getMemberName()) + .companyName(myPageProjection.getCompanyName()) + .pointValance(myPageProjection.getPointValance()) + .build(); + } + } +} diff --git a/src/main/java/com/livable/server/member/dto/MyPageProjection.java b/src/main/java/com/livable/server/member/dto/MyPageProjection.java new file mode 100644 index 00000000..a76af197 --- /dev/null +++ b/src/main/java/com/livable/server/member/dto/MyPageProjection.java @@ -0,0 +1,14 @@ +package com.livable.server.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +@Getter +@AllArgsConstructor +public class MyPageProjection { + + String memberName; + String companyName; + Integer pointValance; +} diff --git a/src/main/java/com/livable/server/member/repository/MemberRepository.java b/src/main/java/com/livable/server/member/repository/MemberRepository.java new file mode 100644 index 00000000..1a2bedf0 --- /dev/null +++ b/src/main/java/com/livable/server/member/repository/MemberRepository.java @@ -0,0 +1,39 @@ +package com.livable.server.member.repository; + +import com.livable.server.entity.Member; +import com.livable.server.member.dto.AccessCardProjection; +import com.livable.server.member.dto.BuildingInfoProjection; +import com.livable.server.member.dto.MyPageProjection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MemberRepository extends JpaRepository { + + @Query("SELECT new com.livable.server.member.dto.MyPageProjection(m.name, c.name, p.balance) " + + "FROM Member m " + + "INNER JOIN Company c ON c.id = m.company.id " + + "INNER JOIN Point p ON p.member.id = m.id " + + "WHERE m.id = :memberId") + Optional findMemberCompanyPointData(@Param("memberId") Long memberId); + + @Query("SELECT distinct new com.livable.server.member.dto.AccessCardProjection(b.name, m.employeeNumber, c.name, o.floor, o.roomNumber, m.name) " + + "FROM Member m " + + "INNER JOIN Company c ON c.id = m.company.id " + + "INNER JOIN Office o ON c.id = o.company.id " + + "INNER JOIN Building b ON b.id = c.building.id " + + "WHERE m.id = :memberId") + List findAccessCardData(@Param("memberId") Long memberId); + + @Query("SELECT new com.livable.server.member.dto.BuildingInfoProjection (b.id, b.name, b.hasCafeteria) " + + "FROM Member m " + + "JOIN Company c " + + "ON m.company = c " + + "JOIN Building b " + + "ON c.building = b " + + "WHERE m.id = :memberId") + Optional findBuildingInfoByMemberId(@Param("memberId") Long memberId); + +} diff --git a/src/main/java/com/livable/server/member/service/MemberService.java b/src/main/java/com/livable/server/member/service/MemberService.java new file mode 100644 index 00000000..28f97b5f --- /dev/null +++ b/src/main/java/com/livable/server/member/service/MemberService.java @@ -0,0 +1,51 @@ +package com.livable.server.member.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.home.dto.HomeResponse; +import com.livable.server.home.dto.HomeResponse.AccessCardDto; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.member.dto.AccessCardProjection; +import com.livable.server.member.dto.BuildingInfoProjection; +import com.livable.server.member.dto.MemberResponse; +import com.livable.server.member.dto.MyPageProjection; +import com.livable.server.member.repository.MemberRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + public MemberResponse.MyPageDTO getMyPageData(Long memberId) { + + Optional myPageProjectionOption = memberRepository.findMemberCompanyPointData(memberId); + MyPageProjection myPageProjection = myPageProjectionOption.orElseThrow(() + -> new GlobalRuntimeException(MemberErrorCode.MEMBER_NOT_EXIST)); + + return MemberResponse.MyPageDTO.from(myPageProjection); + } + + public HomeResponse.AccessCardDto getAccessCardData(Long memberId) { + + List accessCardProjectionOptional = memberRepository.findAccessCardData(memberId); + + if (accessCardProjectionOptional.isEmpty()) { + throw new GlobalRuntimeException(MemberErrorCode.RETRIEVE_ACCESSCARD_FAILED); + } + + return AccessCardDto.from(accessCardProjectionOptional.get(0)); + } + + public HomeResponse.BuildingInfoDto getBuildingInfo(Long memberId) { + Optional buildingInfoProjectionOptional = memberRepository.findBuildingInfoByMemberId(memberId); + BuildingInfoProjection buildingInfoProjection = buildingInfoProjectionOptional.orElseThrow(() + -> new GlobalRuntimeException(MemberErrorCode.BUILDING_INFO_NOT_EXIST)); + + return HomeResponse.BuildingInfoDto.from(buildingInfoProjection); + } + +} diff --git a/src/main/java/com/livable/server/menu/controller/MenuController.java b/src/main/java/com/livable/server/menu/controller/MenuController.java new file mode 100644 index 00000000..1a38f5dc --- /dev/null +++ b/src/main/java/com/livable/server/menu/controller/MenuController.java @@ -0,0 +1,69 @@ +package com.livable.server.menu.controller; + +import static com.livable.server.menu.domain.MenuPaging.MOST_SELECTED_MENU; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.response.ApiResponse.Success; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.menu.dto.MenuRequest.MenuChoiceLogDTO; +import com.livable.server.menu.dto.MenuResponse.MostSelectedMenuDTO; +import com.livable.server.menu.dto.MenuResponse.RouletteMenuDTO; +import com.livable.server.menu.service.MenuService; +import java.util.List; +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class MenuController { + + private final MenuService menuService; + + @GetMapping("/api/menus") + public ResponseEntity>> getRouletteMenus(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + List rouletteMenuDTOs = menuService.getRouletteMenus(); + + return ApiResponse.success(rouletteMenuDTOs, HttpStatus.OK); + } + + @GetMapping("/api/menus/choices") + public ResponseEntity>> getMostSelectedMenu(@RequestParam("buildingId") Long buildingId, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + //추후 가져 오는 메뉴 숫자 변경시 변경 + Pageable pageable = PageRequest.of(0, MOST_SELECTED_MENU.getLimit()); + + List mostSelectedMenu = menuService.getMostSelectedMenu(buildingId, pageable); + + return ApiResponse.success(mostSelectedMenu, HttpStatus.OK); + } + + @PostMapping("/api/menus/choices") + public ResponseEntity createMenuChoiceLog(@Valid @RequestBody MenuChoiceLogDTO menuChoiceLogDTO, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + //오늘 이미 룰렛을 돌렸다면 결과를 반영하지 않고 201 return + menuService.createMenuChoiceLog(memberId, menuChoiceLogDTO); + + return ApiResponse.success(HttpStatus.CREATED); + } + +} diff --git a/src/main/java/com/livable/server/menu/domain/MenuChoiceResultDateRange.java b/src/main/java/com/livable/server/menu/domain/MenuChoiceResultDateRange.java new file mode 100644 index 00000000..380fc387 --- /dev/null +++ b/src/main/java/com/livable/server/menu/domain/MenuChoiceResultDateRange.java @@ -0,0 +1,32 @@ +package com.livable.server.menu.domain; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MenuChoiceResultDateRange { + + private LocalDate startDate; + private LocalDate endDate; + + public static MenuChoiceResultDateRange getDateRange(LocalDate referenceDate) { + + LocalDate startDate; + LocalDate endDate; + + startDate = referenceDate.minusDays( + referenceDate.getDayOfWeek().getValue() - 1L) + .minusWeeks(1); + + endDate = startDate.plusDays(6); + + return MenuChoiceResultDateRange.builder() + .startDate(startDate) + .endDate(endDate) + .build(); + } + +} diff --git a/src/main/java/com/livable/server/menu/domain/MenuErrorCode.java b/src/main/java/com/livable/server/menu/domain/MenuErrorCode.java new file mode 100644 index 00000000..dda1a7bf --- /dev/null +++ b/src/main/java/com/livable/server/menu/domain/MenuErrorCode.java @@ -0,0 +1,22 @@ +package com.livable.server.menu.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum MenuErrorCode implements ErrorCode { + + RETRIEVE_ROULETTE_MENU_FAILED(HttpStatus.BAD_REQUEST, "룰렛 메뉴 정보를 불러 올 수 없습니다."), + + MENU_NOT_EXIST(HttpStatus.BAD_REQUEST, "메뉴를 찾을 수 없습니다."), + + BUILDING_NOT_VALID(HttpStatus.BAD_REQUEST, "빌딩 정보가 일치 하지 않습니다."); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/livable/server/menu/domain/MenuPaging.java b/src/main/java/com/livable/server/menu/domain/MenuPaging.java new file mode 100644 index 00000000..356bd2f4 --- /dev/null +++ b/src/main/java/com/livable/server/menu/domain/MenuPaging.java @@ -0,0 +1,14 @@ +package com.livable.server.menu.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MenuPaging { + + MOST_SELECTED_MENU(10); + + private final Integer limit; + +} diff --git a/src/main/java/com/livable/server/menu/domain/MenuValidationMessage.java b/src/main/java/com/livable/server/menu/domain/MenuValidationMessage.java new file mode 100644 index 00000000..254610eb --- /dev/null +++ b/src/main/java/com/livable/server/menu/domain/MenuValidationMessage.java @@ -0,0 +1,6 @@ +package com.livable.server.menu.domain; + +public interface MenuValidationMessage { + String NOT_NULL = "값이 Null 일수 없습니다."; + +} diff --git a/src/main/java/com/livable/server/menu/dto/MenuChoiceProjection.java b/src/main/java/com/livable/server/menu/dto/MenuChoiceProjection.java new file mode 100644 index 00000000..9e6f88d4 --- /dev/null +++ b/src/main/java/com/livable/server/menu/dto/MenuChoiceProjection.java @@ -0,0 +1,18 @@ +package com.livable.server.menu.dto; + +import com.livable.server.entity.Building; +import com.livable.server.entity.Menu; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MenuChoiceProjection { + + Building building; + Menu menu; + LocalDate date; + Long count; + +} diff --git a/src/main/java/com/livable/server/menu/dto/MenuRequest.java b/src/main/java/com/livable/server/menu/dto/MenuRequest.java new file mode 100644 index 00000000..401e3486 --- /dev/null +++ b/src/main/java/com/livable/server/menu/dto/MenuRequest.java @@ -0,0 +1,34 @@ +package com.livable.server.menu.dto; + + +import static com.livable.server.menu.domain.MenuValidationMessage.NOT_NULL; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.livable.server.core.util.PresentDate; +import java.time.LocalDate; +import javax.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MenuRequest { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class MenuChoiceLogDTO { + + @NotNull(message = NOT_NULL) + private Long menuId; + + @PresentDate + private LocalDate date; + + } + +} diff --git a/src/main/java/com/livable/server/menu/dto/MenuResponse.java b/src/main/java/com/livable/server/menu/dto/MenuResponse.java new file mode 100644 index 00000000..0d649011 --- /dev/null +++ b/src/main/java/com/livable/server/menu/dto/MenuResponse.java @@ -0,0 +1,48 @@ +package com.livable.server.menu.dto; + +import java.time.LocalDate; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MenuResponse { + + @Getter + @AllArgsConstructor + public static class RouletteMenuDTO { + + private String categoryName; + private List menus; + + } + + @Getter + @Builder + @AllArgsConstructor + public static class MostSelectedMenuDTO { + + private LocalDate date; + private Integer count; + private Integer rank; + private Long menuId; + private String menuName; + private String menuImage; + + public static MostSelectedMenuDTO from(MostSelectedMenuProjection mostSelectedMenuProjection, Integer rank) { + return MostSelectedMenuDTO.builder() + .date(mostSelectedMenuProjection.getDate()) + .count(mostSelectedMenuProjection.getCount()) + .rank(rank) + .menuId(mostSelectedMenuProjection.getMenuId()) + .menuName(mostSelectedMenuProjection.menuName) + .menuImage(mostSelectedMenuProjection.getMenuImage()) + .build(); + } + + } + +} diff --git a/src/main/java/com/livable/server/menu/dto/MostSelectedMenuProjection.java b/src/main/java/com/livable/server/menu/dto/MostSelectedMenuProjection.java new file mode 100644 index 00000000..910935ab --- /dev/null +++ b/src/main/java/com/livable/server/menu/dto/MostSelectedMenuProjection.java @@ -0,0 +1,17 @@ +package com.livable.server.menu.dto; + +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MostSelectedMenuProjection { + + Integer count; + LocalDate date; + Long menuId; + String menuName; + String menuImage; + +} diff --git a/src/main/java/com/livable/server/menu/dto/RouletteMenu.java b/src/main/java/com/livable/server/menu/dto/RouletteMenu.java new file mode 100644 index 00000000..ec8ab912 --- /dev/null +++ b/src/main/java/com/livable/server/menu/dto/RouletteMenu.java @@ -0,0 +1,20 @@ +package com.livable.server.menu.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RouletteMenu { + + private Long menuId; + private String name; + + public static RouletteMenu from(RouletteMenuProjection rouletteMenuProjection) { + return RouletteMenu.builder() + .menuId(rouletteMenuProjection.getMenuId()) + .name(rouletteMenuProjection.getName()) + .build(); + } + +} diff --git a/src/main/java/com/livable/server/menu/dto/RouletteMenuProjection.java b/src/main/java/com/livable/server/menu/dto/RouletteMenuProjection.java new file mode 100644 index 00000000..e1fa262c --- /dev/null +++ b/src/main/java/com/livable/server/menu/dto/RouletteMenuProjection.java @@ -0,0 +1,14 @@ +package com.livable.server.menu.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RouletteMenuProjection { + + Long menuId; + String name; + String menuCategoryName; + +} diff --git a/src/main/java/com/livable/server/menu/exception/.gitkeep b/src/main/java/com/livable/server/menu/exception/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/livable/server/menu/repository/MenuChoiceLogRepository.java b/src/main/java/com/livable/server/menu/repository/MenuChoiceLogRepository.java new file mode 100644 index 00000000..74f18d41 --- /dev/null +++ b/src/main/java/com/livable/server/menu/repository/MenuChoiceLogRepository.java @@ -0,0 +1,25 @@ +package com.livable.server.menu.repository; + +import com.livable.server.entity.MenuChoiceLog; +import com.livable.server.menu.dto.MenuChoiceProjection; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface MenuChoiceLogRepository extends JpaRepository { + + Optional findByMemberIdAndDate(Long memberId, LocalDate date); + + @Query("SELECT new com.livable.server.menu.dto.MenuChoiceProjection(mcl.building, mcl.menu, mcl.date, COUNT(mcl.menu.id)) " + + "FROM MenuChoiceLog mcl " + + "WHERE mcl.date = :referenceDate " + + "GROUP BY mcl.building, mcl.menu, mcl.date " + + "ORDER BY count(mcl.menu.id) DESC") + List findMenuChoiceLog(@Param("referenceDate") LocalDate referenceDate); + +} diff --git a/src/main/java/com/livable/server/menu/repository/MenuChoiceResultRepository.java b/src/main/java/com/livable/server/menu/repository/MenuChoiceResultRepository.java new file mode 100644 index 00000000..0ace18ce --- /dev/null +++ b/src/main/java/com/livable/server/menu/repository/MenuChoiceResultRepository.java @@ -0,0 +1,18 @@ +package com.livable.server.menu.repository; + +import com.livable.server.entity.MenuChoiceResult; +import com.livable.server.menu.dto.MenuChoiceProjection; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MenuChoiceResultRepository extends JpaRepository { + @Query("SELECT new com.livable.server.menu.dto.MenuChoiceProjection(mcr.building, mcr.menu, mcr.date, sum(mcr.count)) " + + "FROM MenuChoiceResult mcr " + + "WHERE mcr.date between :startDate AND :endDate " + + "GROUP BY mcr.building, mcr.menu, mcr.date " + + "ORDER BY sum(mcr.count) DESC") + List findMenuChoiceResult(@Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); +} diff --git a/src/main/java/com/livable/server/menu/repository/MenuChoiceWeeklyResultRepository.java b/src/main/java/com/livable/server/menu/repository/MenuChoiceWeeklyResultRepository.java new file mode 100644 index 00000000..74074286 --- /dev/null +++ b/src/main/java/com/livable/server/menu/repository/MenuChoiceWeeklyResultRepository.java @@ -0,0 +1,8 @@ +package com.livable.server.menu.repository; + +import com.livable.server.entity.MenuChoiceWeeklyResult; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MenuChoiceWeeklyResultRepository extends JpaRepository { + +} diff --git a/src/main/java/com/livable/server/menu/repository/MenuRepository.java b/src/main/java/com/livable/server/menu/repository/MenuRepository.java new file mode 100644 index 00000000..9c813d52 --- /dev/null +++ b/src/main/java/com/livable/server/menu/repository/MenuRepository.java @@ -0,0 +1,41 @@ +package com.livable.server.menu.repository; + +import com.livable.server.entity.Menu; +import com.livable.server.menu.dto.MostSelectedMenuProjection; +import com.livable.server.menu.dto.RouletteMenuProjection; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface MenuRepository extends JpaRepository { + + @Query("SELECT distinct new com.livable.server.menu.dto.RouletteMenuProjection(m.id, m.name, mc.name) " + + "FROM Menu m " + + "JOIN MenuCategory mc " + + "ON m.menuCategory.id = mc.id" + ) + List findRouletteMenus(); + + @Query( + "SELECT new com.livable.server.menu.dto.MostSelectedMenuProjection(mcwr.count, mcwr.date, mcwr.menu.id, m.name, m.representativeImageUrl) " + + "FROM MenuChoiceWeeklyResult mcwr " + + "JOIN Menu m " + + "ON m.id = mcwr.menu.id " + + "WHERE mcwr.building.id = :buildingId AND mcwr.date = :referenceDate " + + "GROUP BY mcwr.date, mcwr.menu.id, m.name, m.representativeImageUrl " + + "ORDER BY mcwr.count DESC " + ) + List findMostSelectedMenuOrderByCount(@Param("buildingId") Long buildingId, @Param("referenceDate") LocalDate referenceDate, Pageable pageable); + + @Query( + "SELECT m " + + "FROM Menu m " + + "WHERE m.id in :menuList" + ) + List findAllMenuByMenuId(@Param("menuList") List menuList); +} diff --git a/src/main/java/com/livable/server/menu/service/MenuChoiceResultService.java b/src/main/java/com/livable/server/menu/service/MenuChoiceResultService.java new file mode 100644 index 00000000..3932dbbd --- /dev/null +++ b/src/main/java/com/livable/server/menu/service/MenuChoiceResultService.java @@ -0,0 +1,79 @@ +package com.livable.server.menu.service; + +import com.livable.server.entity.MenuChoiceResult; +import com.livable.server.entity.MenuChoiceWeeklyResult; +import com.livable.server.menu.domain.MenuChoiceResultDateRange; +import com.livable.server.menu.domain.MenuPaging; +import com.livable.server.menu.dto.MenuChoiceProjection; +import com.livable.server.menu.repository.MenuChoiceLogRepository; +import com.livable.server.menu.repository.MenuChoiceResultRepository; +import com.livable.server.menu.repository.MenuChoiceWeeklyResultRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class MenuChoiceResultService { + + private final MenuChoiceLogRepository menuChoiceLogRepository; + private final MenuChoiceResultRepository menuChoiceResultRepository; + private final MenuChoiceWeeklyResultRepository menuChoiceWeeklyResultRepository; + + @Transactional + public void createDailyMenuChoiceResult(LocalDate referenceDate) { + + List menuChoiceResults = new ArrayList<>(); + List menuChoiceLogs = menuChoiceLogRepository.findMenuChoiceLog(referenceDate); + + for (MenuChoiceProjection menuChoiceProjection : menuChoiceLogs) { + + MenuChoiceResult menuChoiceResult = MenuChoiceResult.builder() + .menu(menuChoiceProjection.getMenu()) + .building(menuChoiceProjection.getBuilding()) + .date(menuChoiceProjection.getDate()) + .count((menuChoiceProjection.getCount()).intValue()) + .build(); + + menuChoiceResults.add(menuChoiceResult); + } + + menuChoiceResultRepository.saveAll(menuChoiceResults); + + } + + @Transactional + public void createWeeklyMenuChoiceResult(LocalDate referenceDate) { + + MenuChoiceResultDateRange dateRange = MenuChoiceResultDateRange.getDateRange(referenceDate); + + LocalDate startDate = dateRange.getStartDate(); + LocalDate endDate = dateRange.getEndDate(); + + List menuChoiceProjections = menuChoiceResultRepository.findMenuChoiceResult(startDate, endDate); + List menuChoiceWeeklyResults = new ArrayList<>(); + + for (MenuChoiceProjection menuChoiceProjection : menuChoiceProjections) { + + MenuChoiceWeeklyResult menuChoiceWeeklyResult = MenuChoiceWeeklyResult.builder() + .menu(menuChoiceProjection.getMenu()) + .building(menuChoiceProjection.getBuilding()) + .date(menuChoiceProjection.getDate()) + .count((menuChoiceProjection.getCount()).intValue()) + .build(); + + menuChoiceWeeklyResults.add(menuChoiceWeeklyResult); + if (menuChoiceWeeklyResults.size() > MenuPaging.MOST_SELECTED_MENU.getLimit()) { + break; + } + + } + + menuChoiceWeeklyResultRepository.saveAll(menuChoiceWeeklyResults); + + } + +} diff --git a/src/main/java/com/livable/server/menu/service/MenuService.java b/src/main/java/com/livable/server/menu/service/MenuService.java new file mode 100644 index 00000000..159a81dd --- /dev/null +++ b/src/main/java/com/livable/server/menu/service/MenuService.java @@ -0,0 +1,176 @@ +package com.livable.server.menu.service; + +import com.livable.server.core.exception.ErrorCode; +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Building; +import com.livable.server.entity.Company; +import com.livable.server.entity.Member; +import com.livable.server.entity.Menu; +import com.livable.server.entity.MenuChoiceLog; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.menu.domain.MenuChoiceResultDateRange; +import com.livable.server.menu.domain.MenuErrorCode; +import com.livable.server.menu.dto.MenuRequest.MenuChoiceLogDTO; +import com.livable.server.menu.dto.MenuResponse.MostSelectedMenuDTO; +import com.livable.server.menu.dto.MenuResponse.RouletteMenuDTO; +import com.livable.server.menu.dto.MostSelectedMenuProjection; +import com.livable.server.menu.dto.RouletteMenu; +import com.livable.server.menu.dto.RouletteMenuProjection; +import com.livable.server.menu.repository.MenuChoiceLogRepository; +import com.livable.server.menu.repository.MenuRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class MenuService { + + private final MenuRepository menuRepository; + private final MenuChoiceLogRepository menuChoiceLogRepository; + private final MemberRepository memberRepository; + + public List getRouletteMenus() { + + List rouletteMenuProjections = menuRepository.findRouletteMenus(); + + isValidateRouletteMenus(rouletteMenuProjections, MenuErrorCode.RETRIEVE_ROULETTE_MENU_FAILED); + + Map> rouletteMenuMap = getMenusGroupByMenuCategory(rouletteMenuProjections); + + return convertToDTO(rouletteMenuMap); + } + + private void isValidateRouletteMenus(List projections, ErrorCode errorCode) { + if (projections.isEmpty()) { + throw new GlobalRuntimeException((errorCode)); + } + } + + private Map> getMenusGroupByMenuCategory(List rouletteMenuProjections){ + + Map> menuGroupByMenuCategoryMap = new LinkedHashMap<>(); + + for (RouletteMenuProjection rouletteMenuProjection : rouletteMenuProjections) { + + String menuCategoryName = rouletteMenuProjection.getMenuCategoryName(); + + RouletteMenu rouletteMenus = RouletteMenu.from(rouletteMenuProjection); + + menuGroupByMenuCategoryMap.computeIfAbsent( + menuCategoryName, k -> new ArrayList<>()) + .add(rouletteMenus); + } + + return menuGroupByMenuCategoryMap; + } + + private List convertToDTO(Map> menuGroupByMenuCategoryMap) { + + List rouletteMenuDTOS = new ArrayList<>(); + + menuGroupByMenuCategoryMap.forEach((key, value) -> + rouletteMenuDTOS.add(new RouletteMenuDTO(key, value)) + ); + + return rouletteMenuDTOS; + } + public List getMostSelectedMenu(Long buildingId, Pageable pageable) { + + //기준일(오늘, 지난주 월-일) + LocalDate referenceDate = LocalDate.now(); + + MenuChoiceResultDateRange menuChoiceResultDateRange = MenuChoiceResultDateRange.getDateRange(referenceDate); + + List mostSelectedMenuProjections = menuRepository.findMostSelectedMenuOrderByCount(buildingId, menuChoiceResultDateRange.getEndDate(), pageable); + + return convertToDTO(mostSelectedMenuProjections); + } + + private List convertToDTO(List mostSelectedMenuProjections) { + + if (mostSelectedMenuProjections.isEmpty()) { + return List.of(); + } + + List mostSelectedMenus = new ArrayList<>(); + + for (int i = 0; i < mostSelectedMenuProjections.size(); i++) { + int rank = i + 1; + MostSelectedMenuDTO mostSelectedMenuDTO = MostSelectedMenuDTO.from(mostSelectedMenuProjections.get(i), rank); + mostSelectedMenus.add(mostSelectedMenuDTO); + } + + return mostSelectedMenus; + } + + @Transactional + public void createMenuChoiceLog(Long memberId, MenuChoiceLogDTO menuChoiceLogDTO) { + + Optional menuChoiceLogOptional = findMenuChoiceLogOfToday(memberId); + + MenuChoiceLog menuChoiceLog = getMenuchoiceLog(memberId, menuChoiceLogDTO); + + if(menuChoiceLogOptional.isPresent()) { + menuChoiceLog = menuChoiceLogOptional.get(); + updateMenuChoiceLog(menuChoiceLog, menuChoiceLogDTO); + } + + menuChoiceLogRepository.save(menuChoiceLog); + + } + + private void updateMenuChoiceLog(MenuChoiceLog menuChoiceLog, MenuChoiceLogDTO menuChoiceLogDTO) { + Long selectedId = menuChoiceLogDTO.getMenuId(); + + Menu selectedMenu = menuRepository.findById(selectedId) + .orElseThrow(() -> new GlobalRuntimeException(MenuErrorCode.MENU_NOT_EXIST)); + + menuChoiceLog.updateMenu(selectedMenu); + } + + private MenuChoiceLog getMenuchoiceLog(Long memberId, MenuChoiceLogDTO menuChoiceLogDTO) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GlobalRuntimeException( + MemberErrorCode.MEMBER_NOT_EXIST)); + + Company company = member.getCompany(); + + Building building = company.getBuilding(); + + Long menuId = menuChoiceLogDTO.getMenuId(); + + Menu menu = getMenu(menuId); + + LocalDate date = menuChoiceLogDTO.getDate(); + + return MenuChoiceLog.builder() + .member(member) + .building(building) + .menu(menu) + .date(date) + .build(); + } + + private Menu getMenu(Long menuId) { + return menuRepository.findById(menuId) + .orElseThrow(() -> new GlobalRuntimeException( + MenuErrorCode.MENU_NOT_EXIST) + ); + } + + public Optional findMenuChoiceLogOfToday(Long memberId) { + + return menuChoiceLogRepository.findByMemberIdAndDate(memberId, LocalDate.now()); + } +} diff --git a/src/main/java/com/livable/server/point/controller/PointController.java b/src/main/java/com/livable/server/point/controller/PointController.java new file mode 100644 index 00000000..dca4e857 --- /dev/null +++ b/src/main/java/com/livable/server/point/controller/PointController.java @@ -0,0 +1,47 @@ +package com.livable.server.point.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.point.dto.PointResponse; +import com.livable.server.point.service.PointService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; + +@RequiredArgsConstructor +@RestController +public class PointController { + + private final PointService pointService; + + @GetMapping("/api/points/logs/members") + public ResponseEntity> getMyReviewCount(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + LocalDateTime currentDate = LocalDateTime.now(); + + PointResponse.ReviewCountDTO myReviewCount = pointService.getMyReviewCount(memberId, currentDate); + return ApiResponse.success(myReviewCount, HttpStatus.OK); + } + + @PostMapping("/api/points/logs/members") + public ResponseEntity> getAchievementPoint(@LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + LocalDateTime requestDateTime = LocalDateTime.now(); + + pointService.getAchievementPoint(memberId, requestDateTime); + return ApiResponse.success(HttpStatus.CREATED); + } +} diff --git a/src/main/java/com/livable/server/point/domain/DateFactory.java b/src/main/java/com/livable/server/point/domain/DateFactory.java new file mode 100644 index 00000000..c526360c --- /dev/null +++ b/src/main/java/com/livable/server/point/domain/DateFactory.java @@ -0,0 +1,31 @@ +package com.livable.server.point.domain; + +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; + +@Component +public class DateFactory { + + /** + * 기준이 되는 날짜 정보를 받아 해당 month의 시작과 끝 범위의 데이터를 반환한다. + * + * @param localDateTime + * @return 한달 범위의 시작과 끝 날짜 데이터 + */ + public DateRange getMonthRangeOf(LocalDateTime localDateTime) { + + LocalDateTime startDate = localDateTime.with(TemporalAdjusters.firstDayOfMonth()); + LocalDateTime endDate = localDateTime.plusMonths(1).with(TemporalAdjusters.firstDayOfMonth()); + + return new DateRange(startDate, endDate); + } + + public LocalDate getPureDate(LocalDateTime localDateTime) { + return LocalDate.of(localDateTime.getYear(), + localDateTime.getMonth(), + localDateTime.getDayOfMonth()); + } +} diff --git a/src/main/java/com/livable/server/point/domain/DateRange.java b/src/main/java/com/livable/server/point/domain/DateRange.java new file mode 100644 index 00000000..8bcd7bc1 --- /dev/null +++ b/src/main/java/com/livable/server/point/domain/DateRange.java @@ -0,0 +1,24 @@ +package com.livable.server.point.domain; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class DateRange { + + private final LocalDateTime startDate; + private final LocalDateTime endDate; + + public DateRange(LocalDateTime startDate, LocalDateTime endDate) { + this.startDate = getStartTimeOfMonth(startDate); + this.endDate = getStartTimeOfMonth(endDate); + } + + private LocalDateTime getStartTimeOfMonth(LocalDateTime localDateTime) { + return localDateTime.withHour(0) + .withMinute(0) + .withSecond(0) + .withNano(0); + } +} diff --git a/src/main/java/com/livable/server/point/domain/PointAchievement.java b/src/main/java/com/livable/server/point/domain/PointAchievement.java new file mode 100644 index 00000000..138108a3 --- /dev/null +++ b/src/main/java/com/livable/server/point/domain/PointAchievement.java @@ -0,0 +1,51 @@ +package com.livable.server.point.domain; + +import com.livable.server.entity.PointCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.InputMismatchException; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@AllArgsConstructor +public enum PointAchievement { + + DAY07(7, 100, PointCode.PA03), + DAY14(14, 100, PointCode.PA04), + DAY21(21, 100, PointCode.PA05), + DAY28(28, 100, PointCode.PA06); + + private final Integer dateCount; + private final Integer amount; + private final PointCode pointCode; + + public static final List DAY_COUNTS; + public static final List POINT_CODES; + + static { + DAY_COUNTS = Arrays.stream(PointAchievement.values()) + .map(PointAchievement::getDateCount) + .collect(Collectors.toList()); + + POINT_CODES = Arrays.stream(PointAchievement.values()) + .map(PointAchievement::getPointCode) + .collect(Collectors.toList()); + } + + public static PointAchievement valueOf(Integer count) throws InputMismatchException, IllegalArgumentException { + if (!DAY_COUNTS.contains(count)) { + throw new InputMismatchException(); + } + + for (PointAchievement pointAchievement : PointAchievement.values()) { + Integer dateCount = pointAchievement.getDateCount(); + if (dateCount.equals(count)) { + return pointAchievement; + } + } + throw new IllegalArgumentException(); + } +} diff --git a/src/main/java/com/livable/server/point/domain/PointErrorCode.java b/src/main/java/com/livable/server/point/domain/PointErrorCode.java new file mode 100644 index 00000000..611bb23b --- /dev/null +++ b/src/main/java/com/livable/server/point/domain/PointErrorCode.java @@ -0,0 +1,21 @@ +package com.livable.server.point.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum PointErrorCode implements ErrorCode { + + POINT_NOT_EXIST(HttpStatus.BAD_REQUEST, "존재하지 않는 포인트 정보입니다."), + POINT_NOT_EXIST_FOR_CURRENT_MONTH(HttpStatus.BAD_REQUEST, "현재 달에 지급된 리뷰 포인트가 존재하지 않습니다."), + ACHIEVEMENT_POINT_PAID_FAILED(HttpStatus.BAD_REQUEST, "목표달성 포인트는 당일에만 지급받을 수 있습니다."), + ACHIEVEMENT_POINT_PAID_ALREADY(HttpStatus.BAD_REQUEST, "금일 목표달성 포인트를 이미 지급 받았습니다."), + ACHIEVEMENT_POINT_NOT_MATCHED(HttpStatus.BAD_REQUEST, "목표달성 포인트를 지급 받을 수 있는 리뷰 개수가 부족합니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/livable/server/point/dto/PointResponse.java b/src/main/java/com/livable/server/point/dto/PointResponse.java new file mode 100644 index 00000000..90bcf792 --- /dev/null +++ b/src/main/java/com/livable/server/point/dto/PointResponse.java @@ -0,0 +1,24 @@ +package com.livable.server.point.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PointResponse { + + @Getter + @AllArgsConstructor + public static class ReviewCountDTO { + + private Long count; + } + + @Getter + @AllArgsConstructor + public static class CountAndDateDTO { + + private Long count; + private LocalDateTime mostRecentCreatedDate; + } +} diff --git a/src/main/java/com/livable/server/point/repository/PointLogRepository.java b/src/main/java/com/livable/server/point/repository/PointLogRepository.java new file mode 100644 index 00000000..393efba3 --- /dev/null +++ b/src/main/java/com/livable/server/point/repository/PointLogRepository.java @@ -0,0 +1,32 @@ +package com.livable.server.point.repository; + +import com.livable.server.entity.Point; +import com.livable.server.entity.PointLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface PointLogRepository extends JpaRepository { + + @Query(value = "SELECT * " + + "FROM point_log " + + "WHERE point_id = :pointId " + + "AND paid_at BETWEEN :startDate AND :endDate " + + "ORDER BY paid_at DESC", nativeQuery = true) + List findDateRangeOfPointLogByPointId( + @Param("pointId") Long pointId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate + ); + + + @Query(value = "SELECT p " + + "FROM Point p " + + "WHERE p.member.id = :memberId" + + ) + Point findByMemberId(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/livable/server/point/repository/PointRepository.java b/src/main/java/com/livable/server/point/repository/PointRepository.java new file mode 100644 index 00000000..7d7c929d --- /dev/null +++ b/src/main/java/com/livable/server/point/repository/PointRepository.java @@ -0,0 +1,28 @@ +package com.livable.server.point.repository; + +import com.livable.server.entity.Point; +import com.livable.server.entity.PointCode; +import com.livable.server.point.dto.PointResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface PointRepository extends JpaRepository { + + Optional findByMemberId(Long memberId); + + @Query("SELECT new com.livable.server.point.dto.PointResponse$ReviewCountDTO(COUNT(pl.id)) " + + "FROM PointLog pl " + + "WHERE pl.point.id = :pointId " + + "AND pl.createdAt BETWEEN :startDate AND :endDate " + + "AND pl.code IN (:codes)") + PointResponse.ReviewCountDTO findPointCountById( + @Param("pointId") Long pointId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + @Param("codes") List codes); +} diff --git a/src/main/java/com/livable/server/point/service/PointService.java b/src/main/java/com/livable/server/point/service/PointService.java new file mode 100644 index 00000000..69c906df --- /dev/null +++ b/src/main/java/com/livable/server/point/service/PointService.java @@ -0,0 +1,158 @@ +package com.livable.server.point.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Point; +import com.livable.server.entity.PointCode; +import com.livable.server.entity.PointLog; +import com.livable.server.entity.Review; +import com.livable.server.point.domain.DateFactory; +import com.livable.server.point.domain.DateRange; +import com.livable.server.point.domain.PointAchievement; +import com.livable.server.point.domain.PointErrorCode; +import com.livable.server.point.dto.PointResponse; +import com.livable.server.point.repository.PointLogRepository; +import com.livable.server.point.repository.PointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.InputMismatchException; +import java.util.List; + +@RequiredArgsConstructor +@Service +public class PointService { + + private final PointRepository pointRepository; + private final PointLogRepository pointLogRepository; + private final DateFactory dateFactory; + + @Transactional(readOnly = true) + public PointResponse.ReviewCountDTO getMyReviewCount(Long memberId, LocalDateTime currentDate) { + + Point point = pointRepository.findByMemberId(memberId).orElseThrow(() -> + new GlobalRuntimeException(PointErrorCode.POINT_NOT_EXIST)); + + DateRange dateRange = dateFactory.getMonthRangeOf(currentDate); + + return pointRepository.findPointCountById( + point.getId(), + dateRange.getStartDate(), + dateRange.getEndDate(), + PointCode.getReviewPointCodes() + ); + } + + @Transactional + public void getAchievementPoint(Long memberId, LocalDateTime requestDateTime) { + + // 회원 정보가 유효한지 검증 + final Point point = pointRepository.findByMemberId(memberId).orElseThrow(() -> + new GlobalRuntimeException(PointErrorCode.POINT_NOT_EXIST)); + + // 회원의 요청 날짜에 대한 한달 범위의 포인트 로그를 검색 + final List pointLogs = this.getPointLogPerMonthBy(requestDateTime, point); + PointLog recentPointLog = this.getRecentPointLogFrom(pointLogs); + LocalDate requestDate = dateFactory.getPureDate(requestDateTime); + + // 금일 이미 목표 달성 포인트를 지급받았는지 확인 + this.validationAchievementPointAlreadyPaid(pointLogs, requestDate); + + // 리뷰 개수가 목표 달성으로 치환되는지 확인 (목표를 달성했는지 확인) + PointAchievement pointAchievement = this.getPointAchievementFrom(pointLogs); + + // 목표 포인트 지급 요청 날짜가 포인트를 지급받을 수 있는 날짜인지 확인 + if (!recentPointLog.isPaid(requestDate)) { + throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_PAID_FAILED); + } + + // 포인트 지급 + this.paidPoints(point, pointAchievement, recentPointLog.getReview()); + } + + /** + * point의 포인트 로그중 + * requestDateTime의 year-month에 해당하는 한달 간의 포인트 로그를 반환한다.
+ * 최신 순으로 정렬 + * + * @param requestDateTime LocalDateTime + * @param point Point + * @return 포인트 로그의 리스트 + */ + private List getPointLogPerMonthBy(LocalDateTime requestDateTime, Point point) { + + DateRange dateRangeOfMonth = dateFactory.getMonthRangeOf(requestDateTime); + + return pointLogRepository.findDateRangeOfPointLogByPointId( + point.getId(), dateRangeOfMonth.getStartDate(), dateRangeOfMonth.getEndDate()); + } + + /** + * 최신 순으로 정렬된 PointLog 리스트 중 가장 최근의 포인트 로그를 반환한다.
+ * 포인트 로그 리스트가 비어있다면 예외를 발생한다. + * + * @param pointLogs + * @return PointLog + */ + private PointLog getRecentPointLogFrom(List pointLogs) throws GlobalRuntimeException { + return pointLogs.stream().findFirst() + .orElseThrow(() -> new GlobalRuntimeException(PointErrorCode.POINT_NOT_EXIST_FOR_CURRENT_MONTH)); + } + + /** + * PointLog 리스트 중 requestDate에 목표달성 포인트를 지급 받은 이력이 있는지 확인한다. + * + * @param pointLogs List + * @param requestDate LocalDate + */ + private void validationAchievementPointAlreadyPaid(List pointLogs, LocalDate requestDate) { + pointLogs.stream() + .filter(pointLog -> pointLog.isPaid(requestDate)) + .forEach(pointLog -> { + if (PointAchievement.POINT_CODES.contains(pointLog.getCode())) { + throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_PAID_ALREADY); + } + }); + } + + /** + * PointLog 리스트 중 리뷰를 통해 얻은 포인트 로그의 개수를 + * PointAchievement 객체로 치환하여 반환한다.
+ * 치환이 불가능한 경우 예외를 발생한다. + * + * @param pointLogs List + * @return PointAchievement + */ + private PointAchievement getPointAchievementFrom(List pointLogs) throws GlobalRuntimeException { + try { + int count = (int) pointLogs.stream().filter(pointLog -> { + PointCode code = pointLog.getCode(); + return PointCode.getReviewPointCodes().contains(code); + }).count(); + + return PointAchievement.valueOf(count); + } catch (InputMismatchException exception) { + throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_NOT_MATCHED); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) + public void paidPoints(Point point, PointAchievement pointAchievement, Review review) { + + Integer amount = pointAchievement.getAmount(); + point.plusPoint(amount); + + PointLog pointLog = PointLog.builder() + .point(point) + .review(review) + .code(pointAchievement.getPointCode()) + .amount(pointAchievement.getAmount()) + .build(); + + pointLogRepository.save(pointLog); + } +} diff --git a/src/main/java/com/livable/server/reservation/controller/ReservationController.java b/src/main/java/com/livable/server/reservation/controller/ReservationController.java new file mode 100644 index 00000000..edc46086 --- /dev/null +++ b/src/main/java/com/livable/server/reservation/controller/ReservationController.java @@ -0,0 +1,44 @@ +package com.livable.server.reservation.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.reservation.domain.ReservationRequest; +import com.livable.server.reservation.dto.ReservationResponse; +import com.livable.server.reservation.service.ReservationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/reservation") +@Slf4j +public class ReservationController { + + private final ReservationService reservationService; + + @GetMapping("/places/{commonPlaceId}") + public ResponseEntity> findAvailableTimes( + @PathVariable Long commonPlaceId, + @ModelAttribute ReservationRequest.DateQuery dateQuery, + @LoginActor Actor actor + ) { + log.info("Request Time: {}", LocalDateTime.now()); + JwtTokenProvider.checkMemberToken(actor); + + List result = + reservationService.findAvailableReservationTimes(actor.getId(), commonPlaceId, dateQuery); + if (result.size() != 0) { + log.info("time: {}", result.get(0).getAvailableTimes().get(0).toString()); + } + + return ApiResponse.success(result, HttpStatus.OK); + } +} diff --git a/src/main/java/com/livable/server/reservation/domain/.gitkeep b/src/main/java/com/livable/server/reservation/domain/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/livable/server/reservation/domain/ReservationErrorCode.java b/src/main/java/com/livable/server/reservation/domain/ReservationErrorCode.java new file mode 100644 index 00000000..535e6579 --- /dev/null +++ b/src/main/java/com/livable/server/reservation/domain/ReservationErrorCode.java @@ -0,0 +1,16 @@ +package com.livable.server.reservation.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ReservationErrorCode implements ErrorCode { + + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "날짜 범위가 올바르지 않습니다"); + + private final HttpStatus httpStatus; + private final String message; + } diff --git a/src/main/java/com/livable/server/reservation/domain/ReservationRequest.java b/src/main/java/com/livable/server/reservation/domain/ReservationRequest.java new file mode 100644 index 00000000..204cd2b0 --- /dev/null +++ b/src/main/java/com/livable/server/reservation/domain/ReservationRequest.java @@ -0,0 +1,32 @@ +package com.livable.server.reservation.domain; + +import com.livable.server.core.exception.GlobalRuntimeException; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ReservationRequest { + + + @Getter + public static class DateQuery { + + private final LocalDate startDate; + private final LocalDate endDate; + + public DateQuery(LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + this.startDate = startDate; + this.endDate = endDate; + } + + private void validateRange(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate)) { + throw new GlobalRuntimeException(ReservationErrorCode.INVALID_DATE_RANGE); + } + } + } +} diff --git a/src/main/java/com/livable/server/reservation/dto/.gitkeep b/src/main/java/com/livable/server/reservation/dto/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/livable/server/reservation/dto/AvailableReservationTimeProjection.java b/src/main/java/com/livable/server/reservation/dto/AvailableReservationTimeProjection.java new file mode 100644 index 00000000..a0097259 --- /dev/null +++ b/src/main/java/com/livable/server/reservation/dto/AvailableReservationTimeProjection.java @@ -0,0 +1,16 @@ +package com.livable.server.reservation.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@AllArgsConstructor +public class AvailableReservationTimeProjection { + + private LocalDate date; + private LocalTime time; +} diff --git a/src/main/java/com/livable/server/reservation/dto/AvailableReservationTimeProjections.java b/src/main/java/com/livable/server/reservation/dto/AvailableReservationTimeProjections.java new file mode 100644 index 00000000..212965a5 --- /dev/null +++ b/src/main/java/com/livable/server/reservation/dto/AvailableReservationTimeProjections.java @@ -0,0 +1,29 @@ +package com.livable.server.reservation.dto; + +import lombok.AllArgsConstructor; + +import java.util.List; +import java.util.stream.Collectors; + +@AllArgsConstructor +public class AvailableReservationTimeProjections { + + List projections; + + public List toDto() { + return projections.stream() + .collect(Collectors.groupingBy( + AvailableReservationTimeProjection::getDate, + Collectors.mapping(AvailableReservationTimeProjection::getTime, Collectors.toList()) + )) + .entrySet() + .stream() + .map(entry -> ReservationResponse.AvailableReservationTimePerDateDto + .builder() + .date(entry.getKey()) + .availableTimes(entry.getValue()) + .build() + ) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/livable/server/reservation/dto/ReservationResponse.java b/src/main/java/com/livable/server/reservation/dto/ReservationResponse.java new file mode 100644 index 00000000..387f275e --- /dev/null +++ b/src/main/java/com/livable/server/reservation/dto/ReservationResponse.java @@ -0,0 +1,21 @@ +package com.livable.server.reservation.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ReservationResponse { + + @Getter + @Builder + public static class AvailableReservationTimePerDateDto { + private LocalDate date; + private List availableTimes; + } +} diff --git a/src/main/java/com/livable/server/reservation/repository/.gitkeep b/src/main/java/com/livable/server/reservation/repository/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/livable/server/reservation/repository/ReservationQueryRepository.java b/src/main/java/com/livable/server/reservation/repository/ReservationQueryRepository.java new file mode 100644 index 00000000..3db7e3fd --- /dev/null +++ b/src/main/java/com/livable/server/reservation/repository/ReservationQueryRepository.java @@ -0,0 +1,23 @@ +package com.livable.server.reservation.repository; + +import com.livable.server.entity.Reservation; +import com.livable.server.invitation.dto.InvitationResponse; +import com.livable.server.reservation.dto.AvailableReservationTimeProjection; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface ReservationQueryRepository { + List findReservationsByCompanyId(Long companyId); + List findReservationsByCommonPlaceIdAndStartDateAndEndDate( + Long commonPlaceId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + List findNotUsedReservationTime( + Long companyId, Long commonPlaceId, LocalDate date + ); + + List findNotUsedReservationTimeByUsedReservationIds( + Long companyId, Long commonPlaceId, LocalDate startDate, LocalDate endDate, List reservationIds + ); +} diff --git a/src/main/java/com/livable/server/reservation/repository/ReservationQueryRepositoryImpl.java b/src/main/java/com/livable/server/reservation/repository/ReservationQueryRepositoryImpl.java new file mode 100644 index 00000000..f8443d4f --- /dev/null +++ b/src/main/java/com/livable/server/reservation/repository/ReservationQueryRepositoryImpl.java @@ -0,0 +1,120 @@ +package com.livable.server.reservation.repository; + +import com.livable.server.entity.Reservation; +import com.livable.server.invitation.dto.InvitationResponse; +import com.livable.server.reservation.dto.AvailableReservationTimeProjection; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.livable.server.entity.QCommonPlace.commonPlace; +import static com.livable.server.entity.QInvitationReservationMap.invitationReservationMap; +import static com.livable.server.entity.QReservation.reservation; + +@RequiredArgsConstructor +@Repository +public class ReservationQueryRepositoryImpl implements ReservationQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findReservationsByCompanyId(Long companyId) { + return queryFactory + .selectDistinct(Projections.constructor(InvitationResponse.ReservationDTO.class, + commonPlace.id, + commonPlace.floor, + commonPlace.roomNumber, + commonPlace.name + )) + .from(reservation) + .innerJoin(reservation.commonPlace, commonPlace) + .where( + reservation.company.id.eq(companyId), + reservation.id.notIn( + JPAExpressions + .select(invitationReservationMap.reservation.id) + .from(invitationReservationMap) + .where(invitationReservationMap.reservation.id.eq(reservation.id)) + ) + ) + .orderBy(reservation.commonPlace.id.asc()) + .fetch(); + } + + @Override + public List findReservationsByCommonPlaceIdAndStartDateAndEndDate( + Long commonPlaceId, LocalDateTime startDateTime, LocalDateTime endDateTime + ) { + + return queryFactory + .selectFrom(reservation) + .where( + reservation.commonPlace.id.eq(commonPlaceId), + reservation.date.between( + startDateTime.toLocalDate(), + endDateTime.toLocalDate() + ), + reservation.time.between( + startDateTime.toLocalTime(), + endDateTime.toLocalTime().minusMinutes(30) + ), + reservation.id.notIn( + JPAExpressions + .select(invitationReservationMap.reservation.id) + .from(invitationReservationMap) + .where(invitationReservationMap.reservation.id.eq(reservation.id)) + ) + ) + .orderBy(reservation.date.asc(), reservation.time.asc()) + .fetch(); + } + + @Override + public List findNotUsedReservationTime( + Long companyId, Long commonPlaceId, LocalDate date + ) { + return queryFactory + .select(Projections.constructor(AvailableReservationTimeProjection.class, + reservation.date, + reservation.time + ) + ) + .from(reservation) + .where(reservation.id.notIn( + JPAExpressions.select(invitationReservationMap.reservation.id) + .from(invitationReservationMap) + ), + reservation.company.id.eq(companyId), + reservation.date.eq(date), + reservation.commonPlace.id.eq(commonPlaceId) + ) + .fetch(); + } + + @Override + public List findNotUsedReservationTimeByUsedReservationIds( + Long companyId, Long commonPlaceId, LocalDate startDate, LocalDate endDate, List reservationIds + ) { + return queryFactory + .select(Projections.constructor(AvailableReservationTimeProjection.class, + reservation.date, + reservation.time + ) + ) + .from(reservation) + .where(reservation.id.notIn( + reservationIds + ), + reservation.company.id.eq(companyId), + reservation.date.goe(startDate).and(reservation.date.loe(endDate)), + reservation.commonPlace.id.eq(commonPlaceId) + ) + .fetch(); + } +} diff --git a/src/main/java/com/livable/server/reservation/repository/ReservationRepository.java b/src/main/java/com/livable/server/reservation/repository/ReservationRepository.java new file mode 100644 index 00000000..7b1ad92b --- /dev/null +++ b/src/main/java/com/livable/server/reservation/repository/ReservationRepository.java @@ -0,0 +1,7 @@ +package com.livable.server.reservation.repository; + +import com.livable.server.entity.Reservation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationRepository extends JpaRepository, ReservationQueryRepository { +} diff --git a/src/main/java/com/livable/server/reservation/service/ReservationService.java b/src/main/java/com/livable/server/reservation/service/ReservationService.java new file mode 100644 index 00000000..931cd0a8 --- /dev/null +++ b/src/main/java/com/livable/server/reservation/service/ReservationService.java @@ -0,0 +1,56 @@ +package com.livable.server.reservation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Member; +import com.livable.server.invitation.repository.InvitationReservationMapRepository; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.reservation.domain.ReservationRequest; +import com.livable.server.reservation.dto.AvailableReservationTimeProjection; +import com.livable.server.reservation.dto.AvailableReservationTimeProjections; +import com.livable.server.reservation.dto.ReservationResponse; +import com.livable.server.reservation.repository.ReservationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.*; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final MemberRepository memberRepository; + private final InvitationReservationMapRepository invitationReservationMapRepository; + + public List findAvailableReservationTimes( + Long memberId, + Long commonPlaceId, + ReservationRequest.DateQuery dateQuery + ) { + + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GlobalRuntimeException(MemberErrorCode.MEMBER_NOT_EXIST)); + + AvailableReservationTimeProjections availableReservationTimeProjections = + getAvailableReservationTimeProjections(member.getCompany().getId(), commonPlaceId, dateQuery); + + return availableReservationTimeProjections.toDto(); + } + + private AvailableReservationTimeProjections getAvailableReservationTimeProjections( + Long companyId, Long commonPlaceId, ReservationRequest.DateQuery dateQuery + ) { + List usedReservationIds = invitationReservationMapRepository.findAllReservationId(); + List timeProjections = + reservationRepository.findNotUsedReservationTimeByUsedReservationIds( + companyId, commonPlaceId, dateQuery.getStartDate(), dateQuery.getEndDate(), usedReservationIds + ); + + return new AvailableReservationTimeProjections(timeProjections); + } +} diff --git a/src/main/java/com/livable/server/restaurant/controller/RestaurantController.java b/src/main/java/com/livable/server/restaurant/controller/RestaurantController.java new file mode 100644 index 00000000..a64035ba --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/controller/RestaurantController.java @@ -0,0 +1,103 @@ +package com.livable.server.restaurant.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.response.ApiResponse.Success; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.entity.RestaurantCategory; +import com.livable.server.restaurant.dto.RestaurantResponse; +import com.livable.server.restaurant.dto.RestaurantResponse.ListMenuDTO; +import com.livable.server.restaurant.dto.RestaurantResponse.RestaurantsDto; +import com.livable.server.restaurant.service.RestaurantService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api") +public class RestaurantController { + + private final RestaurantService restaurantService; + + @GetMapping("/restaurant") + public ResponseEntity> findRestaurantByCategory( + @RequestParam("type") RestaurantCategory restaurantCategory, @LoginActor Actor actor + ) { + + JwtTokenProvider.checkVisitorToken(actor); + + Long visitorId = actor.getId(); + + List result = + restaurantService.findNearRestaurantByVisitorIdAndRestaurantCategory(visitorId, restaurantCategory); + + return ApiResponse.success(result, HttpStatus.OK); + } + + + @GetMapping("restaurant/{restaurantId}/menus") + public ResponseEntity>> sellMenuByRestaurant ( + @PathVariable Long restaurantId, + @LoginActor Actor actor + ) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + List result = restaurantService.findMenuList(memberId, restaurantId); + + return ApiResponse.success(result, HttpStatus.OK); + } + + @GetMapping("/restaurants") + public ResponseEntity>> getRestaurantsByMenu( + @RequestParam("menuId") Long menuId, @LoginActor Actor actor + ) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + List restaurantsDtos = restaurantService.findRestaurantByMenuId(menuId, memberId); + + return ApiResponse.success(restaurantsDtos, HttpStatus.OK); + } + + @GetMapping("/restaurants/near") + public ResponseEntity>> getRestaurantsByBuilding( + @RequestParam("buildingId") Long buildingId, @LoginActor Actor actor + ) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + List restaurantsDtos = restaurantService.findRestaurantByBuildingId(buildingId, memberId); + + return ApiResponse.success(restaurantsDtos, HttpStatus.OK); + } + + @GetMapping("/restaurants/search") + public ResponseEntity>> searchRestaurant( + @RequestParam("query") String keyword, + @LoginActor Actor actor + ) { + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + List result = restaurantService.findRestaurantByKeyword(memberId, keyword); + + return ApiResponse.success(result, HttpStatus.OK); + } + +} diff --git a/src/main/java/com/livable/server/restaurant/domain/RandomGenerator.java b/src/main/java/com/livable/server/restaurant/domain/RandomGenerator.java new file mode 100644 index 00000000..982dc641 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/domain/RandomGenerator.java @@ -0,0 +1,17 @@ +package com.livable.server.restaurant.domain; + +import java.util.Random; + +public interface RandomGenerator { + + default int getRandomNumber(int end) { + return getRandomNumber(0, end); + } + + default int getRandomNumber(int start, int end) { + return new Random().nextInt(end) + start; + } + + T getRandom(int end); + T getRandom(int start, int end); +} diff --git a/src/main/java/com/livable/server/restaurant/domain/RandomPageGenerator.java b/src/main/java/com/livable/server/restaurant/domain/RandomPageGenerator.java new file mode 100644 index 00000000..1e8eb3c8 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/domain/RandomPageGenerator.java @@ -0,0 +1,26 @@ +package com.livable.server.restaurant.domain; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +public class RandomPageGenerator implements RandomGenerator { + + private static final int DEFAULT_PAGE_SIZE = 5; + + @Override + public Pageable getRandom(int end) { + return getRandom(0, end); + } + + @Override + public Pageable getRandom(int start, int end) { + if (end < DEFAULT_PAGE_SIZE) { + return PageRequest.of(0, DEFAULT_PAGE_SIZE); + } + + int randomNumber = getRandomNumber(start, end - DEFAULT_PAGE_SIZE + 1); + return PageRequest.of(randomNumber, DEFAULT_PAGE_SIZE); + } +} diff --git a/src/main/java/com/livable/server/restaurant/domain/RestaurantErrorCode.java b/src/main/java/com/livable/server/restaurant/domain/RestaurantErrorCode.java new file mode 100644 index 00000000..8596e34e --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/domain/RestaurantErrorCode.java @@ -0,0 +1,18 @@ +package com.livable.server.restaurant.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum RestaurantErrorCode implements ErrorCode { + NOT_FOUND_CATEGORY(HttpStatus.BAD_REQUEST, "존재하지 않는 식당 종류입니다."), + + NOT_FOUND_RESTAURANT_BY_MENU(HttpStatus.BAD_REQUEST, "해당 메뉴를 제공하는 식당을 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/livable/server/restaurant/dto/RestaurantByMenuProjection.java b/src/main/java/com/livable/server/restaurant/dto/RestaurantByMenuProjection.java new file mode 100644 index 00000000..6db24956 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/dto/RestaurantByMenuProjection.java @@ -0,0 +1,19 @@ +package com.livable.server.restaurant.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RestaurantByMenuProjection { + + Long restaurantId; + String restaurantName; + String restaurantThumbnailUrl; + String address; + Boolean inBuilding; + Integer distance; + String review; + Integer tastePercentage; + +} diff --git a/src/main/java/com/livable/server/restaurant/dto/RestaurantResponse.java b/src/main/java/com/livable/server/restaurant/dto/RestaurantResponse.java new file mode 100644 index 00000000..1cb08a11 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/dto/RestaurantResponse.java @@ -0,0 +1,124 @@ +package com.livable.server.restaurant.dto; + +import com.livable.server.entity.RestaurantCategory; +import lombok.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RestaurantResponse { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class NearRestaurantDto { + + private RestaurantCategory restaurantCategory; + private String restaurantName; + private String restaurantImageUrl; + + private Boolean inBuilding; + + private Integer takenTime; + private Integer floor; + + private String url; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ListMenuDTO { + private Long menuId; + private String menuName; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RestaurantsDto { + + Long restaurantId; + String restaurantName; + Integer tastePercentage; + String representativeImageUrl; + String address; + Integer floor; + Boolean inBuilding; + Integer estimatedTime; + String review; + + public static RestaurantsDto from(RestaurantByMenuProjection restaurantByMenuProjection) { + return RestaurantsDto.builder() + .restaurantId(restaurantByMenuProjection.getRestaurantId()) + .restaurantName(restaurantByMenuProjection.getRestaurantName()) + .tastePercentage(restaurantByMenuProjection.getTastePercentage()) + .representativeImageUrl(restaurantByMenuProjection.getRestaurantThumbnailUrl()) + .address(restaurantByMenuProjection.getAddress()) + .floor(getFloorFromAddress(restaurantByMenuProjection.getAddress())) + .inBuilding(restaurantByMenuProjection.getInBuilding()) + .estimatedTime(calcEstimatedTime(restaurantByMenuProjection.getDistance())) + .review(restaurantByMenuProjection.getReview()) + .build(); + } + } + + @Getter + @Builder + @AllArgsConstructor + public static class SearchRestaurantsDTO { + + private final Long restaurantId; + private final String restaurantName; + private final RestaurantCategory restaurantCategory; + private final Boolean inBuilding; + private final Integer estimatedTime; + private final Integer floor; + private final String thumbnailImageUrl; + + public SearchRestaurantsDTO( + Long restaurantId, + String restaurantName, + RestaurantCategory restaurantCategory, + Boolean inBuilding, + String thumbnailImageUrl, + Integer distance, + String address + ) { + this.restaurantId = restaurantId; + this.restaurantName = restaurantName; + this.restaurantCategory = restaurantCategory; + this.inBuilding = inBuilding; + this.thumbnailImageUrl = thumbnailImageUrl; + this.floor = getFloorFromAddress(address); + this.estimatedTime = calcEstimatedTime(distance); + } + } + + private static Integer getFloorFromAddress(String address) { + + int floor = 0; + + if (address.contains("층")) { + String pattern = "\\s(\\d+)층"; + Pattern regexPattern = Pattern.compile(pattern, Pattern.CANON_EQ); + Matcher matcher = regexPattern.matcher(address); + if (matcher.find() && matcher.group(1) != null) { + + floor = Integer.parseInt(matcher.group(1)); + if (address.contains("지하")) { + floor *= -1; + } + } + } + + return floor; + } + + private static Integer calcEstimatedTime(Integer distance) { + int averageWalkSpeedPerMin = 80; + return distance / averageWalkSpeedPerMin; + } +} diff --git a/src/main/java/com/livable/server/restaurant/repository/BuildingRestaurantMapRepository.java b/src/main/java/com/livable/server/restaurant/repository/BuildingRestaurantMapRepository.java new file mode 100644 index 00000000..2bd534b7 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/repository/BuildingRestaurantMapRepository.java @@ -0,0 +1,13 @@ +package com.livable.server.restaurant.repository; + +import com.livable.server.entity.BuildingRestaurantMap; +import com.livable.server.entity.RestaurantCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BuildingRestaurantMapRepository extends JpaRepository { + + Integer countBuildingRestaurantMapByBuildingIdAndRestaurant_RestaurantCategory( + Long buildingId, + RestaurantCategory restaurantCategory + ); +} diff --git a/src/main/java/com/livable/server/restaurant/repository/RestaurantCustomRepository.java b/src/main/java/com/livable/server/restaurant/repository/RestaurantCustomRepository.java new file mode 100644 index 00000000..c7ceef75 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/repository/RestaurantCustomRepository.java @@ -0,0 +1,21 @@ +package com.livable.server.restaurant.repository; + +import com.livable.server.entity.RestaurantCategory; +import com.livable.server.restaurant.dto.RestaurantResponse; +import com.livable.server.restaurant.dto.RestaurantResponse.ListMenuDTO; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface RestaurantCustomRepository { + + List findRestaurantByBuildingIdAndRestaurantCategory( + Long buildingId, + RestaurantCategory category, + Pageable pageable + ); + + List findMenuList(Long restaurantId); + + List findRestaurantByKeyword(Long buildingId, String keyword); +} diff --git a/src/main/java/com/livable/server/restaurant/repository/RestaurantCustomRepositoryImpl.java b/src/main/java/com/livable/server/restaurant/repository/RestaurantCustomRepositoryImpl.java new file mode 100644 index 00000000..c5eb7095 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/repository/RestaurantCustomRepositoryImpl.java @@ -0,0 +1,140 @@ +package com.livable.server.restaurant.repository; + +import com.livable.server.entity.*; +import com.livable.server.restaurant.dto.RestaurantResponse; +import com.livable.server.restaurant.dto.RestaurantResponse.ListMenuDTO; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.livable.server.entity.QBuilding.building; +import static com.livable.server.entity.QBuildingRestaurantMap.buildingRestaurantMap; +import static com.livable.server.entity.QMenu.menu; +import static com.livable.server.entity.QRestaurant.restaurant; +import static com.livable.server.entity.QRestaurantMenuMap.restaurantMenuMap; + + +@Repository +@RequiredArgsConstructor +public class RestaurantCustomRepositoryImpl implements RestaurantCustomRepository { + + private final JPAQueryFactory queryFactory; + private static final int DISTANCE_PER_TIME = 80; + + @Override + public List findRestaurantByBuildingIdAndRestaurantCategory( + final Long buildingId, + final RestaurantCategory category, + final Pageable pageable + ) { + + final QRestaurant restaurant = QRestaurant.restaurant; + final QBuildingRestaurantMap buildingRestaurantMap = QBuildingRestaurantMap.buildingRestaurantMap; + final QBuilding building = QBuilding.building; + + StringExpression extractFloorFromAddressTemplate = Expressions.stringTemplate( + "REPLACE(REPLACE(SUBSTRING_INDEX({0}, ' ', -1), '지하', '-'), '층', '')", + restaurant.address + ); + + + JPAQuery query = queryFactory + .select(Projections.constructor(RestaurantResponse.NearRestaurantDto.class, + restaurant.restaurantCategory, + restaurant.name, + restaurant.thumbnailImageUrl, + buildingRestaurantMap.inBuilding, + new CaseBuilder() + .when(buildingRestaurantMap.inBuilding.eq(true)) + .then(0) + .otherwise(buildingRestaurantMap.distance + .divide(DISTANCE_PER_TIME) + .round() + .castToNum(Integer.class) + ), + new CaseBuilder() + .when(buildingRestaurantMap.inBuilding.eq(false)) + .then(0) + .otherwise( + extractFloorFromAddressTemplate + .castToNum(Integer.class) + ), + restaurant.restaurantUrl + )) + .from(building) + .innerJoin(buildingRestaurantMap).on(buildingRestaurantMap.building.id.eq(building.id)) + .innerJoin(restaurant).on(buildingRestaurantMap.restaurant.id.eq(restaurant.id)) + .where(building.id.eq(buildingId).and(restaurant.restaurantCategory.eq(category))) + .offset(pageable.getPageNumber()) + .limit(pageable.getPageSize()); + + return query.fetchJoin().fetch(); + } + + @Override + public List findMenuList(Long restaurantId) { + + final QMenu menu = QMenu.menu; + final QRestaurantMenuMap restaurantMenuMap = QRestaurantMenuMap.restaurantMenuMap; + + JPAQuery query = queryFactory + .select(Projections.constructor(ListMenuDTO.class, + menu.id, + menu.name + )) + .from(menu) + .innerJoin(restaurantMenuMap) + .on(menu.id.eq(restaurantMenuMap.menu.id)) + .where(restaurantMenuMap.restaurant.id.eq(restaurantId)); + + return query.fetch(); + } + + @Override + public List findRestaurantByKeyword(Long buildingId, String keyword) { + + // TODO : 성능테스트 필요 + +// List subQuery = queryFactory +// .select(restaurantMenuMap.restaurant.id) +// .from(menu) +// .innerJoin(restaurantMenuMap).on(menu.id.eq(restaurantMenuMap.menu.id)) +// .where(menu.name.contains(keyword) +// .or(restaurant.name.contains(keyword))) +// .fetch(); + + return queryFactory + .select(Projections.constructor(RestaurantResponse.SearchRestaurantsDTO.class, + restaurant.id, + restaurant.name, + restaurant.restaurantCategory, + buildingRestaurantMap.inBuilding, + restaurant.thumbnailImageUrl, + buildingRestaurantMap.distance, + restaurant.address + )) + .from(building) + .innerJoin(buildingRestaurantMap).on(buildingRestaurantMap.building.id.eq(building.id)) + .innerJoin(restaurant).on(restaurant.id.eq(buildingRestaurantMap.restaurant.id)) + .where( + restaurant.id.in( + JPAExpressions + .select(restaurantMenuMap.restaurant.id) + .from(menu) + .innerJoin(restaurantMenuMap).on(menu.id.eq(restaurantMenuMap.menu.id)) + .where(menu.name.contains(keyword) + .or(restaurant.name.contains(keyword))) + ) + ) + .fetch(); + } +} diff --git a/src/main/java/com/livable/server/restaurant/repository/RestaurantGroupByMenuProjectionRepository.java b/src/main/java/com/livable/server/restaurant/repository/RestaurantGroupByMenuProjectionRepository.java new file mode 100644 index 00000000..190bc0f3 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/repository/RestaurantGroupByMenuProjectionRepository.java @@ -0,0 +1,88 @@ +package com.livable.server.restaurant.repository; + +import com.livable.server.restaurant.dto.RestaurantByMenuProjection; +import java.util.List; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.Query; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +public class RestaurantGroupByMenuProjectionRepository { + + private static final String FIND_RESTAURANT_BY_MENU_ID_QUERY; + private static final String FIND_RESTAURANT_BY_BUILDING_ID_QUERY; + + static { + FIND_RESTAURANT_BY_MENU_ID_QUERY = "SELECT res.id as restaurantId, res.name as restaurantName, res.thumbnail_image_url as restaurantThumbnailUrl, res.address as address, " + + "brm.in_building as inBuilding, brm.distance as distance, " + + "MAX(" + + "(SELECT r.description " + + " FROM review r " + + " WHERE r.id = rsv.id " + + " ORDER BY r.created_at DESC " + + " LIMIT 1)) as review, " + + "(SELECT ROUND(SUM(CASE WHEN rsv2.taste = 'good' THEN 1 ELSE 0 END) / COUNT(rsv2.id) * 100, 0) " + + "FROM restaurant_review rsv2 " + + "WHERE rsv2.restaurant_id = res.id) as tastePercentage " + + "FROM member m " + + "JOIN company c " + + "ON m.company_id = c.id " + + "JOIN building_restaurant_map brm " + + "ON c.building_id = brm.building_id " + + "JOIN restaurant res ON brm.restaurant_id = res.id " + + "JOIN restaurant_menu_map rmm " + + "ON res.id = rmm.restaurant_id " + + "LEFT JOIN restaurant_review rsv ON res.id = rsv.restaurant_id " + + "WHERE rmm.menu_id = :menuId AND m.id = :memberId " + + "GROUP BY res.id, res.name, res.thumbnail_image_url, res.address, brm.in_building, brm.distance"; + + FIND_RESTAURANT_BY_BUILDING_ID_QUERY = "SELECT res.id as restaurantId, res.name as restaurantName, res.thumbnail_image_url as restaurantThumbnailUrl, res.address as address, " + + "brm.in_building as inBuilding, brm.distance as distance, " + + "MAX(" + + "(SELECT r.description " + + " FROM review r " + + " WHERE r.id = rsv.id " + + " ORDER BY r.created_at DESC " + + " LIMIT 1)) as review, " + + "(SELECT ROUND(SUM(CASE WHEN rsv2.taste = 'good' THEN 1 ELSE 0 END) / COUNT(rsv2.id) * 100, 0) " + + "FROM restaurant_review rsv2 " + + "WHERE rsv2.restaurant_id = res.id) as tastePercentage " + + "FROM member m " + + "JOIN company c " + + "ON m.company_id = c.id " + + "JOIN building_restaurant_map brm " + + "ON c.building_id = brm.building_id " + + "JOIN restaurant res ON brm.restaurant_id = res.id " + + "JOIN restaurant_menu_map rmm " + + "ON res.id = rmm.restaurant_id " + + "LEFT JOIN restaurant_review rsv ON res.id = rsv.restaurant_id " + + "WHERE brm.building_id = :buildingId AND m.id = :memberId " + + "GROUP BY res.id, res.name, res.thumbnail_image_url, res.address, brm.in_building, brm.distance " + + "Limit :page"; + } + + @PersistenceContext + private EntityManager entityManager; + + public List findRestaurantByMenuId(Long menuId, Long memberId) { + + Query query = entityManager.createNativeQuery(FIND_RESTAURANT_BY_MENU_ID_QUERY, "RestaurantsByMenuMapping") + .setParameter("menuId", menuId) + .setParameter("memberId", memberId); + + return (List) query.getResultList(); + } + + public List findRestaurantByBuildingId(Long buildingId, Long memberId, Pageable pageable) { + + Query query = entityManager.createNativeQuery(FIND_RESTAURANT_BY_BUILDING_ID_QUERY, "RestaurantsByMenuMapping") + .setParameter("buildingId", buildingId) + .setParameter("memberId", memberId) + .setParameter("page", pageable.getPageSize()); + + return (List) query.getResultList(); + } + +} diff --git a/src/main/java/com/livable/server/restaurant/repository/RestaurantRepository.java b/src/main/java/com/livable/server/restaurant/repository/RestaurantRepository.java new file mode 100644 index 00000000..f49e2eb9 --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/repository/RestaurantRepository.java @@ -0,0 +1,7 @@ +package com.livable.server.restaurant.repository; + +import com.livable.server.entity.Restaurant; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RestaurantRepository extends JpaRepository, RestaurantCustomRepository { +} diff --git a/src/main/java/com/livable/server/restaurant/service/RestaurantService.java b/src/main/java/com/livable/server/restaurant/service/RestaurantService.java new file mode 100644 index 00000000..c989a87a --- /dev/null +++ b/src/main/java/com/livable/server/restaurant/service/RestaurantService.java @@ -0,0 +1,124 @@ +package com.livable.server.restaurant.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Member; +import com.livable.server.entity.RestaurantCategory; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.restaurant.domain.RandomGenerator; +import com.livable.server.restaurant.domain.RestaurantErrorCode; +import com.livable.server.restaurant.dto.RestaurantByMenuProjection; +import com.livable.server.restaurant.dto.RestaurantResponse; +import com.livable.server.restaurant.dto.RestaurantResponse.ListMenuDTO; +import com.livable.server.restaurant.dto.RestaurantResponse.RestaurantsDto; +import com.livable.server.restaurant.repository.BuildingRestaurantMapRepository; +import com.livable.server.restaurant.repository.RestaurantGroupByMenuProjectionRepository; +import com.livable.server.restaurant.repository.RestaurantRepository; +import com.livable.server.review.domain.ReviewErrorCode; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.repository.VisitorRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class RestaurantService { + + private final RandomGenerator randomGenerator; + + private final RestaurantRepository restaurantRepository; + private final VisitorRepository visitorRepository; + private final BuildingRestaurantMapRepository buildingRestaurantMapRepository; + private final MemberRepository memberRepository; + private final RestaurantGroupByMenuProjectionRepository restaurantGroupByMenuProjectionRepository; + + public List findNearRestaurantByVisitorIdAndRestaurantCategory( + Long visitorId, RestaurantCategory category + ) { + Long buildingId = visitorRepository.findBuildingIdById(visitorId) + .orElseThrow(() -> new GlobalRuntimeException(VisitationErrorCode.NOT_FOUND)); + + Integer nearRestaurantCount =getNearRestaurantCount(buildingId, category); + + if (nearRestaurantCount == 0) { + return List.of(); + } + + return restaurantRepository.findRestaurantByBuildingIdAndRestaurantCategory( + buildingId, category, randomGenerator.getRandom(nearRestaurantCount) + ); + } + + private Integer getNearRestaurantCount(Long buildingId, RestaurantCategory category) { + return buildingRestaurantMapRepository.countBuildingRestaurantMapByBuildingIdAndRestaurant_RestaurantCategory( + buildingId, + category + ); + } + + public List findMenuList(Long memberId, Long restaurantId) { + checkExistMemberById(memberId); + + return restaurantRepository.findMenuList(restaurantId); + } + + private Member checkExistMemberById(Long memberId) { + Optional memberOptional = memberRepository.findById(memberId); + + return memberOptional.orElseThrow(() -> new GlobalRuntimeException(ReviewErrorCode.MEMBER_NOT_EXIST)); + } + + public List findRestaurantByMenuId(Long menuId, Long memberId) { + List restaurantByMenuProjections = restaurantGroupByMenuProjectionRepository.findRestaurantByMenuId(menuId, memberId); + + if (restaurantByMenuProjections.isEmpty()) { + throw new GlobalRuntimeException(RestaurantErrorCode.NOT_FOUND_RESTAURANT_BY_MENU); + } + + return getRestaurantsByMenu(restaurantByMenuProjections); + } + + public List findRestaurantByBuildingId(Long buildingId, Long memberId) { + + Integer nearRestaurantCount = getNearRestaurantCount(buildingId, RestaurantCategory.RESTAURANT); + + List restaurantByMenuProjections = restaurantGroupByMenuProjectionRepository.findRestaurantByBuildingId(buildingId, memberId, randomGenerator.getRandom(nearRestaurantCount)); + + if (restaurantByMenuProjections.isEmpty()) { + throw new GlobalRuntimeException(RestaurantErrorCode.NOT_FOUND_RESTAURANT_BY_MENU); + } + + return getRestaurantsByMenu(restaurantByMenuProjections); + } + + private List getRestaurantsByMenu( + List restaurantByMenuProjections) { + + List restaurantsDtos = new ArrayList<>(); + + for (RestaurantByMenuProjection restaurantByMenuProjection : restaurantByMenuProjections) { + restaurantsDtos.add(RestaurantsDto.from(restaurantByMenuProjection)); + } + + return restaurantsDtos; + } + + public List findRestaurantByKeyword(Long memberId, String keyword) { + checkExistMemberById(memberId); + + Long buildingId = getBuildingByMember(memberId); + + return restaurantRepository.findRestaurantByKeyword(buildingId, keyword); + } + + private Long getBuildingByMember(Long memberId) { + + return memberRepository.findBuildingInfoByMemberId(memberId).get().getBuildingId(); + } + +} diff --git a/src/main/java/com/livable/server/review/controller/MyReviewController.java b/src/main/java/com/livable/server/review/controller/MyReviewController.java new file mode 100644 index 00000000..98932752 --- /dev/null +++ b/src/main/java/com/livable/server/review/controller/MyReviewController.java @@ -0,0 +1,57 @@ +package com.livable.server.review.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.review.dto.MyReviewResponse; +import com.livable.server.review.service.MyReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class MyReviewController { + + private final MyReviewService myReviewService; + + @GetMapping("/api/reviews/restaurant/{reviewId}/members") + public ResponseEntity> getMyRestaurantReview( + @PathVariable Long reviewId, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + MyReviewResponse.DetailDTO myRestaurantReview = myReviewService.getMyRestaurantReview(reviewId, memberId); + return ApiResponse.success(myRestaurantReview, HttpStatus.OK); + } + + @GetMapping("/api/reviews/cafeteria/{reviewId}/members") + public ResponseEntity> getMyCafeteriaReviewDetail( + @PathVariable Long reviewId, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + MyReviewResponse.DetailDTO myCafeteriaReview = myReviewService.getMyCafeteriaReview(reviewId, memberId); + return ApiResponse.success(myCafeteriaReview, HttpStatus.OK); + } + + @GetMapping("/api/reviews/lunchbox/{reviewId}/members") + public ResponseEntity> getMyLunchboxReviewDetail( + @PathVariable Long reviewId, @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + + Long memberId = actor.getId(); + + MyReviewResponse.DetailDTO myLunchBoxReview = myReviewService.getMyLunchBoxReview(reviewId, memberId); + return ApiResponse.success(myLunchBoxReview, HttpStatus.OK); + } +} diff --git a/src/main/java/com/livable/server/review/controller/RestaurantReviewController.java b/src/main/java/com/livable/server/review/controller/RestaurantReviewController.java new file mode 100644 index 00000000..211d677d --- /dev/null +++ b/src/main/java/com/livable/server/review/controller/RestaurantReviewController.java @@ -0,0 +1,65 @@ +package com.livable.server.review.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.review.dto.RestaurantReviewResponse; +import com.livable.server.review.service.RestaurantReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/reviews") +public class RestaurantReviewController { + + private final RestaurantReviewService restaurantReviewService; + + @GetMapping("/buildings/{buildingId}") + public ResponseEntity>> list( + @PathVariable Long buildingId, + @PageableDefault Pageable pageable) { + + List allListForBuilding = + restaurantReviewService.getAllListForBuilding(buildingId, pageable); + + return ApiResponse.success(allListForBuilding, HttpStatus.OK); + } + + @GetMapping("/menus/{menuId}") + public ResponseEntity>> listForMenu( + @PathVariable Long menuId, + @PageableDefault Pageable pageable) { + + List allListForMenu = + restaurantReviewService.getAllListForMenu(menuId, pageable); + + return ApiResponse.success(allListForMenu, HttpStatus.OK); + } + + @GetMapping("/restaurants/{restaurantId}") + public ResponseEntity>> listForRestaurant( + @PathVariable Long restaurantId, + @PageableDefault Pageable pageable) { + + List allListForRestaurant = + restaurantReviewService.getAllListForRestaurant(restaurantId, pageable); + + return ApiResponse.success(allListForRestaurant, HttpStatus.OK); + } + + @GetMapping("/{reviewId}") + public ResponseEntity> detail(@PathVariable Long reviewId) { + + RestaurantReviewResponse.DetailDTO detail = restaurantReviewService.getDetail(reviewId); + + return ApiResponse.success(detail, HttpStatus.OK); + } +} diff --git a/src/main/java/com/livable/server/review/controller/ReviewController.java b/src/main/java/com/livable/server/review/controller/ReviewController.java new file mode 100644 index 00000000..b4328123 --- /dev/null +++ b/src/main/java/com/livable/server/review/controller/ReviewController.java @@ -0,0 +1,100 @@ +package com.livable.server.review.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.response.ApiResponse.Success; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.review.dto.ReviewRequest; +import com.livable.server.review.dto.ReviewResponse; +import com.livable.server.review.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/reviews") +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping(value = "/lunch-box", consumes = "multipart/form-data") + public ResponseEntity createLunchBoxReview( + @Valid @RequestPart("data") ReviewRequest.LunchBoxCreateDTO lunchBoxCreateDTO, + @RequestPart(value = "imageFiles", required = false) List files, + @LoginActor Actor actor + ) throws IOException { + + JwtTokenProvider.checkMemberToken(actor); + Long memberId = actor.getId(); + + reviewService.createLunchBoxReview(lunchBoxCreateDTO, memberId, files); + + return ApiResponse.success(HttpStatus.CREATED); + } + + @PostMapping(value = "/cafeteria", consumes = "multipart/form-data") + public ResponseEntity createCafeteriaReview( + @Valid @RequestPart("data") ReviewRequest.CafeteriaCreateDTO CafeteriaCreateDTO, + @RequestPart(value = "imageFiles", required = false) List files, + @LoginActor Actor actor + ) throws IOException { + + JwtTokenProvider.checkMemberToken(actor); + Long memberId = actor.getId(); + + reviewService.createCafeteriaReview(CafeteriaCreateDTO, memberId, files); + + return ApiResponse.success(HttpStatus.CREATED); + } + + @PostMapping(value = "/restaurant", consumes = "multipart/form-data") + public ResponseEntity createRestaurantReview( + @Valid @RequestPart("data") ReviewRequest.RestaurantCreateDTO restaurantCreateDTO, + @RequestPart(value = "imageFiles", required = false) List files, + @LoginActor Actor actor + ) throws IOException { + + JwtTokenProvider.checkMemberToken(actor); + Long memberId = actor.getId(); + + reviewService.createRestaurantReview(restaurantCreateDTO, memberId, files); + + return ApiResponse.success(HttpStatus.CREATED); + } + + @GetMapping("/members") + public ResponseEntity>> calendarListReview( + @RequestParam("year") String year, + @RequestParam("month") String month, + @LoginActor Actor actor + ) { + + JwtTokenProvider.checkMemberToken(actor); + Long memberId = actor.getId(); + + List result = reviewService.findCalendarList(memberId, year, month); + + return ApiResponse.success(result, HttpStatus.OK); + } + + @GetMapping("/detail/members") + public ResponseEntity>> findAllReviewDetail( + @RequestParam Integer year, + @RequestParam Integer month, + @LoginActor Actor actor) { + + JwtTokenProvider.checkMemberToken(actor); + Long memberId = actor.getId(); + + List result = reviewService.findAllReviewDetailList(memberId, year, month); + return ApiResponse.success(result, HttpStatus.OK); + } +} diff --git a/src/main/java/com/livable/server/review/domain/MyReview.java b/src/main/java/com/livable/server/review/domain/MyReview.java new file mode 100644 index 00000000..7ffe6fc5 --- /dev/null +++ b/src/main/java/com/livable/server/review/domain/MyReview.java @@ -0,0 +1,53 @@ +package com.livable.server.review.domain; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.review.dto.MyReviewProjection; +import com.livable.server.review.dto.MyReviewResponse; + +import java.util.List; +import java.util.stream.Collectors; + +public class MyReview { + + private final List reviews; + + private MyReview(List reviews) { + validationReviews(reviews); + this.reviews = reviews; + } + + private void validationReviews(List reviews) { + if (reviews.isEmpty()) { + throw new GlobalRuntimeException(MyReviewErrorCode.REVIEW_NOT_EXIST); + } + } + + public static MyReview from(List reviews) { + return new MyReview(reviews); + } + + public MyReviewResponse.DetailDTO toResponseDTO() { + + MyReviewProjection myReviewDTO = this.getTopOne(); + List images = this.getImages(); + + return MyReviewResponse.DetailDTO.builder() + .reviewTitle(myReviewDTO.getReviewTitle()) + .reviewTaste(myReviewDTO.getReviewTaste()) + .reviewDescription(myReviewDTO.getReviewDescription()) + .reviewCreatedAt(myReviewDTO.getReviewCreatedAt()) + .location(myReviewDTO.getLocation()) + .reviewImg(images) + .build(); + } + + private MyReviewProjection getTopOne() { + return reviews.get(0); + } + + private List getImages() { + return reviews.stream() + .map(MyReviewProjection::getReviewImg) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/livable/server/review/domain/MyReviewErrorCode.java b/src/main/java/com/livable/server/review/domain/MyReviewErrorCode.java new file mode 100644 index 00000000..4c3baf89 --- /dev/null +++ b/src/main/java/com/livable/server/review/domain/MyReviewErrorCode.java @@ -0,0 +1,17 @@ +package com.livable.server.review.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum MyReviewErrorCode implements ErrorCode { + + REVIEW_NOT_EXIST(HttpStatus.BAD_REQUEST, "존재하지 않는 리뷰 정보입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/livable/server/review/domain/PointReview.java b/src/main/java/com/livable/server/review/domain/PointReview.java new file mode 100644 index 00000000..cde9ca47 --- /dev/null +++ b/src/main/java/com/livable/server/review/domain/PointReview.java @@ -0,0 +1,18 @@ +package com.livable.server.review.domain; + +import com.livable.server.entity.PointCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PointReview { + + RESTAURANT_POINT(PointCode.PA00, 10), + CAFETERIA_POINT(PointCode.PA01, 10), + LUNCHBOX_POINT(PointCode.PA02, 10); + + private final PointCode pointCode; + private final Integer amount; + +} diff --git a/src/main/java/com/livable/server/review/domain/ReviewErrorCode.java b/src/main/java/com/livable/server/review/domain/ReviewErrorCode.java new file mode 100644 index 00000000..4dcbafdf --- /dev/null +++ b/src/main/java/com/livable/server/review/domain/ReviewErrorCode.java @@ -0,0 +1,30 @@ +package com.livable.server.review.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ReviewErrorCode implements ErrorCode { + MEMBER_NOT_EXIST(HttpStatus.BAD_REQUEST, "존재하지 않는 회원 정보입니다."), + RESTAURANT_NOT_EXITST(HttpStatus.BAD_REQUEST, "존재하지 않는 음식점 정보입니다."), + MENUS_NOT_CHOICE(HttpStatus.BAD_REQUEST, "하나 이상의 메뉴를 선택해 주세요."), + ALREADY_HAVE_A_REVIEW(HttpStatus.BAD_REQUEST, "리뷰는 하루에 한 개만 작성이 가능합니다."); + + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/livable/server/review/domain/ReviewSelectType.java b/src/main/java/com/livable/server/review/domain/ReviewSelectType.java new file mode 100644 index 00000000..6432df70 --- /dev/null +++ b/src/main/java/com/livable/server/review/domain/ReviewSelectType.java @@ -0,0 +1,18 @@ +package com.livable.server.review.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ReviewSelectType { + LUNCH_BOX("도시락"), + CAFETERIA("구내식당"); + + private final String message; + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/livable/server/review/dto/MenuRequest.java b/src/main/java/com/livable/server/review/dto/MenuRequest.java new file mode 100644 index 00000000..ebabce8f --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/MenuRequest.java @@ -0,0 +1,13 @@ +package com.livable.server.review.dto; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MenuRequest { + + private Long menuId; + private String menuName; +} diff --git a/src/main/java/com/livable/server/review/dto/MyReviewProjection.java b/src/main/java/com/livable/server/review/dto/MyReviewProjection.java new file mode 100644 index 00000000..3d7c2864 --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/MyReviewProjection.java @@ -0,0 +1,29 @@ +package com.livable.server.review.dto; + +import com.livable.server.entity.Evaluation; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class MyReviewProjection { + + private String reviewTitle; + private Evaluation reviewTaste; + private String reviewDescription; + private LocalDateTime reviewCreatedAt; + private String location; + private String reviewImg; + + public MyReviewProjection(String reviewTitle, String reviewDescription, LocalDateTime reviewCreatedAt, String reviewImg) { + this.reviewTitle = reviewTitle; + this.reviewDescription = reviewDescription; + this.reviewCreatedAt = reviewCreatedAt; + this.reviewImg = reviewImg; + } +} diff --git a/src/main/java/com/livable/server/review/dto/MyReviewResponse.java b/src/main/java/com/livable/server/review/dto/MyReviewResponse.java new file mode 100644 index 00000000..36e2d6e9 --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/MyReviewResponse.java @@ -0,0 +1,26 @@ +package com.livable.server.review.dto; + +import com.livable.server.entity.Evaluation; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MyReviewResponse { + + @Getter + @Builder + public static class DetailDTO { + + private String reviewTitle; + private Evaluation reviewTaste; + private String reviewDescription; + private LocalDateTime reviewCreatedAt; + private List reviewImg; + private String location; + } +} diff --git a/src/main/java/com/livable/server/review/dto/Projection.java b/src/main/java/com/livable/server/review/dto/Projection.java new file mode 100644 index 00000000..d756e1e3 --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/Projection.java @@ -0,0 +1,79 @@ +package com.livable.server.review.dto; + +import com.livable.server.entity.Evaluation; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.Objects; + +public class Projection { + + @Getter + @Builder + @AllArgsConstructor + static public class RestaurantReview { + + private String memberName; + + private Long restaurantId; + private String restaurantName; + + private LocalDateTime reviewCreatedAt; + private String reviewDescription; + private Evaluation reviewTaste; + private Evaluation reviewAmount; + private Evaluation reviewService; + private Evaluation reviewSpeed; + + private String images; + } + + @Getter + @Builder + @AllArgsConstructor + public static class RestaurantReviewList { + + private Long memberId; + private String memberName; + + private Long restaurantId; + private String restaurantName; + + private Long reviewId; + private LocalDateTime reviewCreatedAt; + private String reviewDescription; + private Evaluation reviewTaste; + private Evaluation reviewAmount; + private Evaluation reviewService; + private Evaluation reviewSpeed; + + private String images; + } + + @Getter + @Builder + @AllArgsConstructor + public static class AllReviewDetailDTO { + + private Long reviewId; + private String reviewTitle; + private Evaluation reviewTaste; + private String reviewDescription; + private String reviewCreatedAt; + private String location; + private String images; + private String reviewType; + + public AllReviewDetailDTO(Long reviewId, String reviewTitle, String reviewTaste, String reviewDescription, String reviewCreatedAt, String location, String images, String reviewType) { + + this.reviewId = reviewId; + this.reviewTitle = reviewTitle; + this.reviewTaste = Objects.isNull(reviewTaste) ? null : Evaluation.valueOf(reviewTaste); + this.reviewDescription = reviewDescription; + this.reviewCreatedAt = reviewCreatedAt; + this.location = location; + this.images = images; + this.reviewType = reviewType; + } + } +} diff --git a/src/main/java/com/livable/server/review/dto/RestaurantReviewProjection.java b/src/main/java/com/livable/server/review/dto/RestaurantReviewProjection.java new file mode 100644 index 00000000..de23d11c --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/RestaurantReviewProjection.java @@ -0,0 +1,57 @@ +package com.livable.server.review.dto; + +import com.livable.server.entity.Evaluation; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class RestaurantReviewProjection { + + private String memberName; + private String memberProfileImage; + + private Long restaurantId; + private String restaurantName; + + private Long reviewId; + private LocalDateTime reviewCreatedAt; + private String reviewDescription; + private Evaluation reviewTaste; + private Evaluation reviewAmount; + private Evaluation reviewService; + private Evaluation reviewSpeed; + + private String images; + + public RestaurantReviewProjection(String memberName, + String memberProfileImage, + Long restaurantId, + String restaurantName, + Long reviewId, + LocalDateTime reviewCreatedAt, + String reviewDescription, + String reviewTaste, + String reviewAmount, + String reviewService, + String reviewSpeed, + String images) { + + this.memberName = memberName; + this.memberProfileImage = memberProfileImage; + this.restaurantId = restaurantId; + this.restaurantName = restaurantName; + this.reviewId = reviewId; + this.reviewCreatedAt = reviewCreatedAt; + this.reviewDescription = reviewDescription; + this.reviewTaste = Evaluation.valueOf(reviewTaste); + this.reviewAmount = Evaluation.valueOf(reviewAmount); + this.reviewService = Evaluation.valueOf(reviewService); + this.reviewSpeed = Evaluation.valueOf(reviewSpeed); + this.images = images; + } +} diff --git a/src/main/java/com/livable/server/review/dto/RestaurantReviewResponse.java b/src/main/java/com/livable/server/review/dto/RestaurantReviewResponse.java new file mode 100644 index 00000000..3b95fde1 --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/RestaurantReviewResponse.java @@ -0,0 +1,160 @@ +package com.livable.server.review.dto; + +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.entity.Evaluation; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RestaurantReviewResponse { + + @Getter + @Builder + public static class ListForBuildingDTO { + + private final String memberName; + private final String memberProfileImage; + + private final Long restaurantId; + private final String restaurantName; + + private final Long reviewId; + private final LocalDateTime reviewCreatedAt; + private final String reviewDescription; + private final Evaluation reviewTaste; + private final Evaluation reviewAmount; + private final Evaluation reviewService; + private final Evaluation reviewSpeed; + + private List reviewImages; + + public static ListForBuildingDTO valueOf(RestaurantReviewProjection restaurantReviewList, ImageSeparator imageSeparator) { + return ListForBuildingDTO.builder() + .memberName(restaurantReviewList.getMemberName()) + .memberProfileImage(restaurantReviewList.getMemberProfileImage()) + .restaurantId(restaurantReviewList.getRestaurantId()) + .restaurantName(restaurantReviewList.getRestaurantName()) + .reviewId(restaurantReviewList.getReviewId()) + .reviewCreatedAt(restaurantReviewList.getReviewCreatedAt()) + .reviewDescription(restaurantReviewList.getReviewDescription()) + .reviewTaste(restaurantReviewList.getReviewTaste()) + .reviewAmount(restaurantReviewList.getReviewAmount()) + .reviewService(restaurantReviewList.getReviewService()) + .reviewSpeed(restaurantReviewList.getReviewSpeed()) + .reviewImages(imageSeparator.separateConcatenatedImages(restaurantReviewList.getImages())) + .build(); + } + } + + @Getter + @Builder + public static class ListForRestaurantDTO { + + private final String memberName; + private final String memberProfileImage; + + private final Long restaurantId; + private final String restaurantName; + + private final Long reviewId; + private final LocalDateTime reviewCreatedAt; + private final String reviewDescription; + private final Evaluation reviewTaste; + private final Evaluation reviewAmount; + private final Evaluation reviewService; + private final Evaluation reviewSpeed; + + private List reviewImages; + + public static ListForRestaurantDTO valueOf(RestaurantReviewProjection restaurantReviewList, ImageSeparator imageSeparator) { + return ListForRestaurantDTO.builder() + .memberName(restaurantReviewList.getMemberName()) + .memberProfileImage(restaurantReviewList.getMemberProfileImage()) + .restaurantId(restaurantReviewList.getRestaurantId()) + .restaurantName(restaurantReviewList.getRestaurantName()) + .reviewId(restaurantReviewList.getReviewId()) + .reviewCreatedAt(restaurantReviewList.getReviewCreatedAt()) + .reviewDescription(restaurantReviewList.getReviewDescription()) + .reviewTaste(restaurantReviewList.getReviewTaste()) + .reviewAmount(restaurantReviewList.getReviewAmount()) + .reviewService(restaurantReviewList.getReviewService()) + .reviewSpeed(restaurantReviewList.getReviewSpeed()) + .reviewImages(imageSeparator.separateConcatenatedImages(restaurantReviewList.getImages())) + .build(); + } + } + + @Getter + @Builder + public static class ListForMenuDTO { + + private final String memberName; + private final String memberProfileImage; + + private final Long restaurantId; + private final String restaurantName; + + private final Long reviewId; + private final LocalDateTime reviewCreatedAt; + private final String reviewDescription; + private final Evaluation reviewTaste; + private final Evaluation reviewAmount; + private final Evaluation reviewService; + private final Evaluation reviewSpeed; + + private List reviewImages; + + public static ListForMenuDTO valueOf(RestaurantReviewProjection restaurantReviewList, ImageSeparator imageSeparator) { + return ListForMenuDTO.builder() + .memberName(restaurantReviewList.getMemberName()) + .memberProfileImage(restaurantReviewList.getMemberProfileImage()) + .restaurantId(restaurantReviewList.getRestaurantId()) + .restaurantName(restaurantReviewList.getRestaurantName()) + .reviewId(restaurantReviewList.getReviewId()) + .reviewCreatedAt(restaurantReviewList.getReviewCreatedAt()) + .reviewDescription(restaurantReviewList.getReviewDescription()) + .reviewTaste(restaurantReviewList.getReviewTaste()) + .reviewAmount(restaurantReviewList.getReviewAmount()) + .reviewService(restaurantReviewList.getReviewService()) + .reviewSpeed(restaurantReviewList.getReviewSpeed()) + .reviewImages(imageSeparator.separateConcatenatedImages(restaurantReviewList.getImages())) + .build(); + } + } + + @Getter + @Builder + public static class DetailDTO { + + private String memberName; + + private Long restaurantId; + private String restaurantName; + + private LocalDateTime reviewCreatedAt; + private String reviewDescription; + private Evaluation reviewTaste; + private Evaluation reviewAmount; + private Evaluation reviewService; + private Evaluation reviewSpeed; + + private List reviewImages; + + public static DetailDTO from(Projection.RestaurantReview restaurantReview, List reviewImages) { + return DetailDTO.builder() + .memberName(restaurantReview.getMemberName()) + .restaurantId(restaurantReview.getRestaurantId()) + .restaurantName(restaurantReview.getRestaurantName()) + .reviewCreatedAt(restaurantReview.getReviewCreatedAt()) + .reviewDescription(restaurantReview.getReviewDescription()) + .reviewTaste(restaurantReview.getReviewTaste()) + .reviewAmount(restaurantReview.getReviewAmount()) + .reviewService(restaurantReview.getReviewService()) + .reviewSpeed(restaurantReview.getReviewSpeed()) + .reviewImages(reviewImages) + .build(); + } + } +} diff --git a/src/main/java/com/livable/server/review/dto/ReviewRequest.java b/src/main/java/com/livable/server/review/dto/ReviewRequest.java new file mode 100644 index 00000000..baf9301e --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/ReviewRequest.java @@ -0,0 +1,82 @@ +package com.livable.server.review.dto; + +import com.livable.server.entity.*; +import lombok.*; +import lombok.extern.jackson.Jacksonized; + +import javax.validation.constraints.NotNull; + +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewRequest { + + @Getter + @Builder + @Jacksonized + public static class LunchBoxCreateDTO { + // JWT 토큰 오고 => MemberId + @NotNull(message = "내용을 입력해 주세요.") + private String description; + + + public LunchBoxReview toEntity(Member member, String selectedDishes) { + return LunchBoxReview.builder() + .member(member) + .description(description) + .selectedDishes(selectedDishes) + .build(); + } + } + + @Getter + @Builder + public static class CafeteriaCreateDTO { + + private Evaluation taste; + + @NotNull(message = "내용을 입력해 주세요.") + private String description; + + public CafeteriaReview toEntity(Member member, Building building, String selectedDishes) { + return CafeteriaReview.builder() + .member(member) + .taste(taste) + .description(description) + .selectedDishes(selectedDishes) + .building(building) + .build(); + } + } + + @Getter + @Builder + public static class RestaurantCreateDTO { + + private Long restaurantId; + + @NotNull(message = "내용을 입력해 주세요.") + private String description; + + private Evaluation taste; + private Evaluation amount; + private Evaluation speed; + private Evaluation service; + + private List menus; + + + public RestaurantReview toEntity(Member member, Restaurant restaurant, String selectedDishes) { + return RestaurantReview.builder() + .member(member) + .restaurant(restaurant) + .taste(taste) + .amount(amount) + .speed(speed) + .service(service) + .description(description) + .selectedDishes(selectedDishes) + .build(); + } + } +} diff --git a/src/main/java/com/livable/server/review/dto/ReviewResponse.java b/src/main/java/com/livable/server/review/dto/ReviewResponse.java new file mode 100644 index 00000000..2149826e --- /dev/null +++ b/src/main/java/com/livable/server/review/dto/ReviewResponse.java @@ -0,0 +1,54 @@ +package com.livable.server.review.dto; + +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.entity.Evaluation; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +public class ReviewResponse { + + @Getter + public static class CalendarListDTO { + private Long reviewId; + private String type; + private String reviewImageUrl; + private LocalDate reviewDate; + + public CalendarListDTO(Long reviewId, String type, String reviewImageUrl, LocalDate reviewDate) { + this.reviewId = reviewId; + this.type = type; + this.reviewImageUrl = reviewImageUrl; + this.reviewDate = reviewDate; + } + } + + @Getter + @Builder + public static class DetailListDTO { + + private Long reviewId; + private String reviewTitle; + private Evaluation reviewTaste; + private String reviewDescription; + private String reviewCreatedAt; + private String location; + private List images; + private String reviewType; + + public static DetailListDTO valueOf(Projection.AllReviewDetailDTO detailDTO, ImageSeparator imageSeparator) { + return DetailListDTO.builder() + .reviewId(detailDTO.getReviewId()) + .reviewTitle(detailDTO.getReviewTitle()) + .reviewTaste(detailDTO.getReviewTaste()) + .reviewDescription(detailDTO.getReviewDescription()) + .reviewCreatedAt(detailDTO.getReviewCreatedAt()) + .location(detailDTO.getLocation()) + .images(imageSeparator.separateConcatenatedImages(detailDTO.getImages())) + .reviewType(detailDTO.getReviewType()) + .build(); + } + } +} diff --git a/src/main/java/com/livable/server/review/repository/MyReviewRepository.java b/src/main/java/com/livable/server/review/repository/MyReviewRepository.java new file mode 100644 index 00000000..f4e1dc15 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/MyReviewRepository.java @@ -0,0 +1,11 @@ +package com.livable.server.review.repository; + +import com.livable.server.entity.Review; +import com.livable.server.review.repository.querydsl.MyReviewQueryDslRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MyReviewRepository + extends JpaRepository, MyReviewQueryDslRepository { +} diff --git a/src/main/java/com/livable/server/review/repository/RestaurantReviewProjectionRepository.java b/src/main/java/com/livable/server/review/repository/RestaurantReviewProjectionRepository.java new file mode 100644 index 00000000..6cfb2f53 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/RestaurantReviewProjectionRepository.java @@ -0,0 +1,136 @@ +package com.livable.server.review.repository; + +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.review.dto.RestaurantReviewProjection; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.Query; +import java.util.List; + +@Repository +public class RestaurantReviewProjectionRepository { + + private static final String FIND_RESTAURANT_REVIEW_BY_BUILDING_ID_QUERY; + private static final String FIND_RESTAURANT_REVIEW_BY_RESTAURANT_ID_QUERY; + private static final String FIND_RESTAURANT_REVIEW_BY_MENU_ID_QUERY; + + static { + FIND_RESTAURANT_REVIEW_BY_BUILDING_ID_QUERY = "SELECT " + + "m.name AS memberName, " + + "m.profile_image_url AS memberProfileImage, " + + "res.id AS restaurantId, " + + "res.name AS restaurantName, " + + "r.id AS reviewId, " + + "r.created_at AS reviewCreatedAt, " + + "r.description AS reviewDescription, " + + "rr.taste AS reviewTaste, " + + "rr.amount AS reviewAmount, " + + "rr.service AS reviewService, " + + "rr.speed AS reviewSpeed, " + + "GROUP_CONCAT(ri.url SEPARATOR :separator) AS images " + + "FROM review r " + + "INNER JOIN restaurant_review rr ON r.id = rr.id " + + "INNER JOIN member m ON r.member_id = m.id " + + "INNER JOIN restaurant res ON rr.restaurant_id = res.id " + + "LEFT JOIN review_image ri ON ri.review_id = r.id " + + "WHERE rr.restaurant_id IN " + + "(SELECT restaurant_id " + + "FROM building_restaurant_map " + + "WHERE building_id = :buildingId) " + + "GROUP BY r.id, rr.id, m.id, res.id " + + "ORDER BY r.created_at DESC " + + "LIMIT :limit " + + "OFFSET :offset"; + + FIND_RESTAURANT_REVIEW_BY_RESTAURANT_ID_QUERY = "SELECT " + + "m.name AS memberName, " + + "m.profile_image_url AS memberProfileImage, " + + "res.id AS restaurantId, " + + "res.name AS restaurantName, " + + "r.id AS reviewId, " + + "r.created_at AS reviewCreatedAt, " + + "r.description AS reviewDescription, " + + "rr.taste AS reviewTaste, " + + "rr.amount AS reviewAmount, " + + "rr.service AS reviewService, " + + "rr.speed AS reviewSpeed, " + + "GROUP_CONCAT(ri.url SEPARATOR :separator) AS images " + + "FROM review r " + + "INNER JOIN restaurant_review rr ON r.id = rr.id " + + "INNER JOIN member m ON r.member_id = m.id " + + "INNER JOIN restaurant res ON rr.restaurant_id = res.id " + + "LEFT JOIN review_image ri ON ri.review_id = r.id " + + "WHERE rr.restaurant_id = :restaurantId " + + "GROUP BY r.id, rr.id, m.id, res.id " + + "ORDER BY r.created_at DESC " + + "LIMIT :limit " + + "OFFSET :offset"; + + FIND_RESTAURANT_REVIEW_BY_MENU_ID_QUERY = "SELECT " + + "m.name AS memberName, " + + "m.profile_image_url AS memberProfileImage, " + + "res.id AS restaurantId, " + + "res.name AS restaurantName, " + + "r.id AS reviewId, " + + "r.created_at AS reviewCreatedAt, " + + "r.description AS reviewDescription, " + + "rr.taste AS reviewTaste, " + + "rr.amount AS reviewAmount, " + + "rr.service AS reviewService, " + + "rr.speed AS reviewSpeed, " + + "GROUP_CONCAT(ri.url SEPARATOR :separator) AS images " + + "FROM review r " + + "INNER JOIN restaurant_review rr ON rr.id = r.id " + + "INNER JOIN member m ON m.id = r.member_id " + + "INNER JOIN restaurant res ON rr.restaurant_id = res.id " + + "LEFT JOIN review_image ri ON ri.review_id = r.id " + + "WHERE r.id IN ( " + + "SELECT review_menu_map.review_id " + + "FROM review_menu_map " + + "WHERE review_menu_map.menu_id = :menuId " + + ") " + + "GROUP BY r.id, rr.id, m.id, res.id " + + "ORDER BY r.created_at DESC " + + "LIMIT :limit " + + "OFFSET :offset"; + } + + @PersistenceContext + private EntityManager entityManager; + + public List findRestaurantReviewProjectionByBuildingId(Long buildingId, Pageable pageable) { + + Query query = entityManager.createNativeQuery(FIND_RESTAURANT_REVIEW_BY_BUILDING_ID_QUERY, "RestaurantReviewListMapping") + .setParameter("separator", ImageSeparator.IMAGE_SEPARATOR) + .setParameter("buildingId", buildingId) + .setParameter("limit", pageable.getPageSize()) + .setParameter("offset", pageable.getOffset()); + + return (List) query.getResultList(); + } + + public List findRestaurantReviewProjectionByRestaurantId(Long restaurantId, Pageable pageable) { + + Query query = entityManager.createNativeQuery(FIND_RESTAURANT_REVIEW_BY_RESTAURANT_ID_QUERY, "RestaurantReviewListMapping") + .setParameter("separator", ImageSeparator.IMAGE_SEPARATOR) + .setParameter("restaurantId", restaurantId) + .setParameter("limit", pageable.getPageSize()) + .setParameter("offset", pageable.getOffset()); + + return (List) query.getResultList(); + } + + public List findRestaurantReviewProjectionByMenuId(Long menuId, Pageable pageable) { + + Query query = entityManager.createNativeQuery(FIND_RESTAURANT_REVIEW_BY_MENU_ID_QUERY, "RestaurantReviewListMapping") + .setParameter("separator", ImageSeparator.IMAGE_SEPARATOR) + .setParameter("menuId", menuId) + .setParameter("limit", pageable.getPageSize()) + .setParameter("offset", pageable.getOffset()); + + return (List) query.getResultList(); + } +} diff --git a/src/main/java/com/livable/server/review/repository/RestaurantReviewRepository.java b/src/main/java/com/livable/server/review/repository/RestaurantReviewRepository.java new file mode 100644 index 00000000..60664d0e --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/RestaurantReviewRepository.java @@ -0,0 +1,11 @@ +package com.livable.server.review.repository; + +import com.livable.server.entity.RestaurantReview; +import com.livable.server.review.repository.querydsl.RestaurantReviewQueryDslRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RestaurantReviewRepository + extends JpaRepository, RestaurantReviewQueryDslRepository { +} diff --git a/src/main/java/com/livable/server/review/repository/ReviewImageRepository.java b/src/main/java/com/livable/server/review/repository/ReviewImageRepository.java new file mode 100644 index 00000000..0e6de810 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/ReviewImageRepository.java @@ -0,0 +1,7 @@ +package com.livable.server.review.repository; + +import com.livable.server.entity.ReviewImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/livable/server/review/repository/ReviewMenuMapRepository.java b/src/main/java/com/livable/server/review/repository/ReviewMenuMapRepository.java new file mode 100644 index 00000000..074275a2 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/ReviewMenuMapRepository.java @@ -0,0 +1,7 @@ +package com.livable.server.review.repository; + +import com.livable.server.entity.ReviewMenuMap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewMenuMapRepository extends JpaRepository { +} diff --git a/src/main/java/com/livable/server/review/repository/ReviewProjectionRepository.java b/src/main/java/com/livable/server/review/repository/ReviewProjectionRepository.java new file mode 100644 index 00000000..74feb80b --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/ReviewProjectionRepository.java @@ -0,0 +1,135 @@ +package com.livable.server.review.repository; + +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.ReviewResponse; +import org.springframework.stereotype.Repository; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.Query; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class ReviewProjectionRepository { + + private static final String FIND_ALL_REVIEWS_BY_YEAR_AND_MONTH_QUERY; + private static final String FIND_ALL_REVIEW_DETAIL_BETWEEN_DATE_QUERY; + + static { + FIND_ALL_REVIEWS_BY_YEAR_AND_MONTH_QUERY = "select " + + "result.id as reviewId, " + + "result.type as type, " + + "result.url as reviewImageUrl, " + + "result.created_at as reviewDate " + + "from ( " + + "select r.id, date_format(r.created_at, \"%Y-%m-%d\") as created_at, " + + "(select ri3.url " + + "from review_image ri3 " + + "where ri3.id = min(ri.id)) as url, " + + "'cafeteria' as type " + + "from review r " + + "inner join cafeteria_review cr on cr.id = r.id " + + "left join review_image ri " + + "on ri.review_id = r.id " + + "group by r.id " + + "union " + + "select r.id, date_format(r.created_at, \"%Y-%m-%d\") as created_at, " + + "(select ri3.url " + + "from review_image ri3 " + + "where ri3.id = min(ri.id)) as url, " + + "'lunchbox' as type " + + "from review r " + + "inner join lunch_box_review lr on lr.id = r.id " + + "left join review_image ri " + + "on ri.review_id = r.id " + + "group by r.id " + + "union " + + "select r.id, date_format(r.created_at, \"%Y-%m-%d\") as created_at, " + + "(select ri3.url " + + "from review_image ri3 " + + "where ri3.id = min(ri.id)) as url, " + + "'restaurant' as type " + + "from review r " + + "inner join restaurant_review rr on rr.id = r.id " + + "left join review_image ri " + + "on ri.review_id = r.id " + + "group by r.id) as result " + + "where year(result.created_at) = :year and month(result.created_at) = :month " + + "order by result.created_at"; + + FIND_ALL_REVIEW_DETAIL_BETWEEN_DATE_QUERY = "SELECT * " + + "FROM (" + + "SELECT " + + "review.id as reviewId, " + + "review.selected_dishes as reviewTitle, " + + "restaurant_review.taste as reviewTaste, " + + "review.description as reviewDescription, " + + "date_format(review.created_at, \"%Y-%m-%d\") as reviewCreatedAt, " + + "restaurant.name as location, " + + "GROUP_CONCAT(review_image.url SEPARATOR :separator) AS images, " + + "'restaurant' AS reviewType " + + "FROM review " + + "LEFT JOIN review_image ON review_image.review_id = review.id " + + "INNER JOIN restaurant_review on restaurant_review.id = review.id " + + "INNER JOIN restaurant on restaurant.id = restaurant_review.restaurant_id " + + "WHERE review.member_id = :memberId " + + "GROUP BY review.id, review.member_id " + + "UNION " + + "SELECT " + + "review.id as reviewId, " + + "review.selected_dishes as reviewTitle, " + + "cafeteria_review.taste as reviewTaste, " + + "review.description as reviewDescription, " + + "date_format(review.created_at, \"%Y-%m-%d\") as reviewCreatedAt, " + + "building.name as location, " + + "GROUP_CONCAT(review_image.url SEPARATOR :separator) AS images, " + + "'cafeteria' AS reviewType " + + "FROM review " + + "LEFT JOIN review_image ON review_image.review_id = review.id " + + "INNER JOIN cafeteria_review on cafeteria_review.id = review.id " + + "INNER JOIN building on building.id = cafeteria_review.building_id " + + "WHERE review.member_id = :memberId " + + "GROUP BY review.id, review.member_id " + + "UNION " + + "SELECT " + + "review.id as reviewId, " + + "review.selected_dishes as reviewTitle, " + + "NULL as reviewTaste, " + + "review.description as reviewDescription, " + + "date_format(review.created_at, \"%Y-%m-%d\") as reviewCreatedAt, " + + "NULL as location, " + + "GROUP_CONCAT(review_image.url SEPARATOR :separator) AS images, " + + "'lunchBox' AS reviewType " + + "FROM review " + + "LEFT JOIN review_image ON review_image.review_id = review.id " + + "INNER JOIN lunch_box_review on lunch_box_review.id = review.id " + + "WHERE review.member_id = :memberId " + + "GROUP BY review.id, review.member_id " + + ") as data " + + "WHERE data.reviewCreatedAt BETWEEN :startDate AND :endDate " + + "ORDER BY data.reviewCreatedAt"; + } + + @PersistenceContext + private EntityManager entityManager; + + public List findCalendarListByYearAndMonth(String year, String month) { + Query query = entityManager.createNativeQuery(FIND_ALL_REVIEWS_BY_YEAR_AND_MONTH_QUERY, "ReviewAllList") + .setParameter("year", year) + .setParameter("month", month); + + return query.getResultList(); + } + + public List findAllReviewDetailBetween(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { + Query query = entityManager.createNativeQuery(FIND_ALL_REVIEW_DETAIL_BETWEEN_DATE_QUERY, "AllReviewDetailListMapping") + .setParameter("separator", ImageSeparator.IMAGE_SEPARATOR) + .setParameter("memberId", memberId) + .setParameter("startDate", startDate) + .setParameter("endDate", endDate); + + return query.getResultList(); + } +} diff --git a/src/main/java/com/livable/server/review/repository/ReviewRepository.java b/src/main/java/com/livable/server/review/repository/ReviewRepository.java new file mode 100644 index 00000000..adf69ab8 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/ReviewRepository.java @@ -0,0 +1,20 @@ +package com.livable.server.review.repository; + +import com.livable.server.entity.Review; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ReviewRepository extends JpaRepository { + + @Query(value = + "SELECT count(r) " + + "FROM Review r " + + "WHERE r.member.id = :memberId and DATE(r.createdAt) = current_date " + ) + Long findBymemberIdAndDate(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/livable/server/review/repository/querydsl/MyReviewQueryDslRepository.java b/src/main/java/com/livable/server/review/repository/querydsl/MyReviewQueryDslRepository.java new file mode 100644 index 00000000..52f48ebd --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/querydsl/MyReviewQueryDslRepository.java @@ -0,0 +1,16 @@ +package com.livable.server.review.repository.querydsl; + +import com.livable.server.review.dto.MyReviewProjection; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MyReviewQueryDslRepository { + + List findRestaurantReviewByReviewId(Long reviewId, Long memberId); + + List findLunchBoxReviewByReviewId(Long reviewId, Long memberId); + + List findCafeteriaReviewByReviewId(Long reviewId, Long memberId); +} diff --git a/src/main/java/com/livable/server/review/repository/querydsl/MyReviewQueryDslRepositoryImpl.java b/src/main/java/com/livable/server/review/repository/querydsl/MyReviewQueryDslRepositoryImpl.java new file mode 100644 index 00000000..66368333 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/querydsl/MyReviewQueryDslRepositoryImpl.java @@ -0,0 +1,76 @@ +package com.livable.server.review.repository.querydsl; + +import com.livable.server.review.dto.MyReviewProjection; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static com.livable.server.entity.QBuilding.building; +import static com.livable.server.entity.QCafeteriaReview.cafeteriaReview; +import static com.livable.server.entity.QRestaurant.restaurant; +import static com.livable.server.entity.QRestaurantReview.restaurantReview; +import static com.livable.server.entity.QReview.review; +import static com.livable.server.entity.QReviewImage.reviewImage; + +@Component +@RequiredArgsConstructor +public class MyReviewQueryDslRepositoryImpl implements MyReviewQueryDslRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findRestaurantReviewByReviewId(Long reviewId, Long memberId) { + return jpaQueryFactory + .select(Projections.constructor(MyReviewProjection.class, + review.selectedDishes, + restaurantReview.taste, + review.description, + review.createdAt, + restaurant.name, + reviewImage.url + )) + .from(review) + .leftJoin(reviewImage).on(reviewImage.review.id.eq(review.id)) + .innerJoin(restaurantReview).on(restaurantReview.id.eq(review.id)) + .innerJoin(restaurant).on(restaurant.id.eq(restaurantReview.restaurant.id)) + .where(review.id.eq(reviewId).and(review.member.id.eq(memberId))) + .fetch(); + } + + @Override + public List findLunchBoxReviewByReviewId(Long reviewId, Long memberId) { + return jpaQueryFactory + .select(Projections.constructor(MyReviewProjection.class, + review.selectedDishes, + review.description, + review.createdAt, + reviewImage.url + )) + .from(review) + .leftJoin(reviewImage).on(reviewImage.review.id.eq(review.id)) + .where(review.id.eq(reviewId).and(review.member.id.eq(memberId))) + .fetch(); + } + + @Override + public List findCafeteriaReviewByReviewId(Long reviewId, Long memberId) { + return jpaQueryFactory + .select(Projections.constructor(MyReviewProjection.class, + review.selectedDishes, + cafeteriaReview.taste, + review.description, + review.createdAt, + building.name, + reviewImage.url + )) + .from(review) + .leftJoin(reviewImage).on(reviewImage.review.id.eq(review.id)) + .innerJoin(cafeteriaReview).on(cafeteriaReview.id.eq(review.id)) + .innerJoin(building).on(building.id.eq(cafeteriaReview.building.id)) + .where(review.id.eq(reviewId).and(review.member.id.eq(memberId))) + .fetch(); + } +} diff --git a/src/main/java/com/livable/server/review/repository/querydsl/RestaurantReviewQueryDslRepository.java b/src/main/java/com/livable/server/review/repository/querydsl/RestaurantReviewQueryDslRepository.java new file mode 100644 index 00000000..babf7e0c --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/querydsl/RestaurantReviewQueryDslRepository.java @@ -0,0 +1,15 @@ +package com.livable.server.review.repository.querydsl; + +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.RestaurantReviewResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface RestaurantReviewQueryDslRepository { + + Page findRestaurantReviewByMenuId(Long menuId, Pageable pageable); + + List findRestaurantReviewById(Long reviewId); +} diff --git a/src/main/java/com/livable/server/review/repository/querydsl/RestaurantReviewQueryDslRepositoryImpl.java b/src/main/java/com/livable/server/review/repository/querydsl/RestaurantReviewQueryDslRepositoryImpl.java new file mode 100644 index 00000000..2a83a015 --- /dev/null +++ b/src/main/java/com/livable/server/review/repository/querydsl/RestaurantReviewQueryDslRepositoryImpl.java @@ -0,0 +1,90 @@ +package com.livable.server.review.repository.querydsl; + +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.RestaurantReviewResponse; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static com.livable.server.entity.QMember.member; +import static com.livable.server.entity.QRestaurant.restaurant; +import static com.livable.server.entity.QRestaurantReview.restaurantReview; +import static com.livable.server.entity.QReview.review; +import static com.livable.server.entity.QReviewImage.reviewImage; +import static com.livable.server.entity.QReviewMenuMap.reviewMenuMap; + +@RequiredArgsConstructor +public class RestaurantReviewQueryDslRepositoryImpl implements RestaurantReviewQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findRestaurantReviewByMenuId(Long menuId, Pageable pageable) { + + JPAQuery query = queryFactory + .select(Projections.constructor(RestaurantReviewResponse.ListForMenuDTO.class, + review.id, + review.createdAt, + review.description, + restaurantReview.taste, + restaurantReview.amount, + restaurantReview.service, + restaurantReview.speed, + restaurantReview.restaurant.id, + restaurant.name, + review.member.id, + member.name + )) + .from(review) + .innerJoin(restaurantReview).on(restaurantReview.id.eq(review.id)) + .innerJoin(member).on(review.member.id.eq(member.id)) + .innerJoin(restaurant).on(restaurantReview.restaurant.id.eq(restaurant.id)) + .where(review.id.in( + JPAExpressions + .select(reviewMenuMap.review.id) + .from(reviewMenuMap) + .where(reviewMenuMap.menu.id.eq(menuId)) + )) + .orderBy(review.createdAt.desc()); + + List content = query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetchJoin().fetch(); + + long total = query.fetchCount(); + + return new PageImpl<>(content, pageable, total); + } + + @Override + public List findRestaurantReviewById(Long reviewId) { + return queryFactory + .select(Projections.constructor(Projection.RestaurantReview.class, + member.name, + restaurant.id, + restaurant.name, + review.createdAt, + review.description, + restaurantReview.taste, + restaurantReview.amount, + restaurantReview.service, + restaurantReview.speed, + reviewImage.url + )) + .from(review) + .innerJoin(restaurantReview).on(restaurantReview.id.eq(review.id)) + .innerJoin(member).on(member.id.eq(review.member.id)) + .innerJoin(restaurant).on(restaurant.id.eq(restaurantReview.restaurant.id)) + .leftJoin(reviewImage).on(reviewImage.review.id.eq(review.id)) + .where(review.id.eq(reviewId)) + .fetch(); + } +} diff --git a/src/main/java/com/livable/server/review/service/MyReviewService.java b/src/main/java/com/livable/server/review/service/MyReviewService.java new file mode 100644 index 00000000..eaee08ef --- /dev/null +++ b/src/main/java/com/livable/server/review/service/MyReviewService.java @@ -0,0 +1,51 @@ +package com.livable.server.review.service; + +import com.livable.server.review.domain.MyReview; +import com.livable.server.review.dto.MyReviewProjection; +import com.livable.server.review.dto.MyReviewResponse; +import com.livable.server.review.repository.MyReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class MyReviewService { + + private final MyReviewRepository myReviewRepository; + + @Transactional(readOnly = true) + public MyReviewResponse.DetailDTO getMyRestaurantReview(Long reviewId, Long memberId) { + + List myReviewProjections + = myReviewRepository.findRestaurantReviewByReviewId(reviewId, memberId); + + return this.convertToDTO(myReviewProjections); + } + + @Transactional(readOnly = true) + public MyReviewResponse.DetailDTO getMyCafeteriaReview(Long reviewId, Long memberId) { + + List myReviewProjections + = myReviewRepository.findCafeteriaReviewByReviewId(reviewId, memberId); + + return this.convertToDTO(myReviewProjections); + } + + @Transactional(readOnly = true) + public MyReviewResponse.DetailDTO getMyLunchBoxReview(Long reviewId, Long memberId) { + + List myReviewProjections + = myReviewRepository.findLunchBoxReviewByReviewId(reviewId, memberId); + + return this.convertToDTO(myReviewProjections); + } + + private MyReviewResponse.DetailDTO convertToDTO(List myReviewProjections) { + + MyReview myReview = MyReview.from(myReviewProjections); + return myReview.toResponseDTO(); + } +} diff --git a/src/main/java/com/livable/server/review/service/RestaurantReviewService.java b/src/main/java/com/livable/server/review/service/RestaurantReviewService.java new file mode 100644 index 00000000..6864217a --- /dev/null +++ b/src/main/java/com/livable/server/review/service/RestaurantReviewService.java @@ -0,0 +1,80 @@ +package com.livable.server.review.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.review.domain.MyReviewErrorCode; +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.RestaurantReviewProjection; +import com.livable.server.review.dto.RestaurantReviewResponse; +import com.livable.server.review.repository.RestaurantReviewProjectionRepository; +import com.livable.server.review.repository.RestaurantReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class RestaurantReviewService { + + private final RestaurantReviewRepository restaurantReviewRepository; + private final RestaurantReviewProjectionRepository restaurantProjectionRepository; + private final ImageSeparator imageSeparator; + + @Transactional(readOnly = true) + public List getAllListForBuilding(Long buildingId, Pageable pageable) { + + List restaurantReviewLists = + restaurantProjectionRepository.findRestaurantReviewProjectionByBuildingId(buildingId, pageable); + + return restaurantReviewLists.stream() + .map(restaurantReviewList -> + RestaurantReviewResponse.ListForBuildingDTO.valueOf(restaurantReviewList, imageSeparator)) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getAllListForRestaurant(Long restaurantId, Pageable pageable) { + + List reviewProjections = + restaurantProjectionRepository.findRestaurantReviewProjectionByRestaurantId(restaurantId, pageable); + + return reviewProjections.stream() + .map(reviewProjection -> + RestaurantReviewResponse.ListForRestaurantDTO.valueOf(reviewProjection, imageSeparator)) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getAllListForMenu(Long menuId, Pageable pageable) { + List reviewProjections = + restaurantProjectionRepository.findRestaurantReviewProjectionByMenuId(menuId, pageable); + + return reviewProjections.stream() + .map(reviewProjection -> + RestaurantReviewResponse.ListForMenuDTO.valueOf(reviewProjection, imageSeparator)) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public RestaurantReviewResponse.DetailDTO getDetail(Long reviewId) { + + List restaurantReviews + = restaurantReviewRepository.findRestaurantReviewById(reviewId); + + if (restaurantReviews.isEmpty()) { + throw new GlobalRuntimeException(MyReviewErrorCode.REVIEW_NOT_EXIST); + } + + Projection.RestaurantReview restaurantReview = restaurantReviews.get(0); + List reviewImages = restaurantReviews.stream() + .map(Projection.RestaurantReview::getImages) + .collect(Collectors.toList()); + + return RestaurantReviewResponse.DetailDTO.from(restaurantReview, reviewImages); + } +} diff --git a/src/main/java/com/livable/server/review/service/ReviewService.java b/src/main/java/com/livable/server/review/service/ReviewService.java new file mode 100644 index 00000000..b88048c0 --- /dev/null +++ b/src/main/java/com/livable/server/review/service/ReviewService.java @@ -0,0 +1,249 @@ +package com.livable.server.review.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.core.util.S3Uploader; +import com.livable.server.entity.*; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.menu.repository.MenuRepository; +import com.livable.server.point.repository.PointLogRepository; +import com.livable.server.point.domain.DateFactory; +import com.livable.server.point.domain.DateRange; +import com.livable.server.restaurant.repository.RestaurantRepository; +import com.livable.server.review.domain.PointReview; +import com.livable.server.review.domain.ReviewErrorCode; +import com.livable.server.review.dto.MenuRequest; +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.ReviewRequest; +import com.livable.server.review.dto.ReviewResponse; +import com.livable.server.review.repository.ReviewImageRepository; +import com.livable.server.review.repository.ReviewMenuMapRepository; +import com.livable.server.review.repository.ReviewProjectionRepository; +import com.livable.server.review.repository.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.livable.server.review.domain.ReviewSelectType.*; + +@RequiredArgsConstructor +@Service +public class ReviewService { + + private final MenuRepository menuRepository; + private final ReviewRepository reviewRepository; + private final MemberRepository memberRepository; + private final PointLogRepository pointLogRepository; + private final RestaurantRepository restaurantRepository; + private final ReviewImageRepository reviewImageRepository; + private final ReviewMenuMapRepository reviewMenuMapRepository; + private final ReviewProjectionRepository reviewProjectionRepository; + private final S3Uploader s3Uploader; + private final DateFactory dateFactory; + private final ImageSeparator imageSeparator; + + @Transactional + public void createLunchBoxReview(ReviewRequest.LunchBoxCreateDTO lunchBoxCreateDTO, Long memberId, List files) throws IOException { + Member member = findMemberById(memberId); + Review review = lunchBoxCreateDTO.toEntity(member, LUNCH_BOX.getMessage()); + Long reviewCount = reviewRepository.findBymemberIdAndDate(memberId); + + if (reviewCount == 0) { + reviewRepository.save(review); + } else { + throw new GlobalRuntimeException(ReviewErrorCode.ALREADY_HAVE_A_REVIEW); + } + + List images = s3Uploader.saveFile(files); + + if (!images.isEmpty()) { + Point point = pointLogRepository.findByMemberId(memberId); + + // add point + paidPoints(point, PointReview.LUNCHBOX_POINT, review); + + // register image + List reviewImages = saveImageFiles(review, images); + + reviewImageRepository.saveAll(reviewImages); + } + } + + @Transactional + public void createCafeteriaReview(ReviewRequest.CafeteriaCreateDTO cafeteriaCreateDTO, Long memberId, List files) throws IOException { + Member member = findMemberById(memberId); + Building building = getBuildingByMember(member); + Review review = cafeteriaCreateDTO.toEntity(member, building, CAFETERIA.getMessage()); + Long reviewCount = reviewRepository.findBymemberIdAndDate(memberId); + + if (reviewCount == 0) { + reviewRepository.save(review); + } else { + throw new GlobalRuntimeException(ReviewErrorCode.ALREADY_HAVE_A_REVIEW); + } + + List images = s3Uploader.saveFile(files); + + if (!images.isEmpty()) { + Point point = pointLogRepository.findByMemberId(memberId); + + // add point + paidPoints(point, PointReview.CAFETERIA_POINT, review); + + // register image + List reviewImages = saveImageFiles(review, images); + + reviewImageRepository.saveAll(reviewImages); + } + } + + @Transactional + public void createRestaurantReview(ReviewRequest.RestaurantCreateDTO restaurantCreateDTO, Long memberId, List files) throws IOException { + String selectedDishes = ""; + StringBuffer sb = new StringBuffer(); + List menuList = new ArrayList<>(); + List reviewMenuMapList = new ArrayList<>(); + + // request menu + List menu = restaurantCreateDTO.getMenus(); + Long restaurantId = restaurantCreateDTO.getRestaurantId(); + + Member member = findMemberById(memberId); + Restaurant restaurant = findRestaurantById(restaurantId); + + Long reviewCount = reviewRepository.findBymemberIdAndDate(memberId); + + // menu valid + if (menu.isEmpty()) { + + throw new GlobalRuntimeException(ReviewErrorCode.MENUS_NOT_CHOICE); + } + + // menu 리스트 순회 + menu.forEach(el -> { + if (el.getMenuId() > 0) { + menuList.add(el.getMenuId()); + } + + // selected dishes 용 + if (sb.length() > 0) { + sb.append(","); + } + sb.append(el.getMenuName()); + }); + + selectedDishes = sb.substring(0, sb.length()); + Review review = restaurantCreateDTO.toEntity(member, restaurant, selectedDishes); + + List menus = menuRepository.findAllMenuByMenuId(menuList); + + menus.forEach(el -> { + reviewMenuMapList.add(ReviewMenuMap.builder() + .menu(el) + .review(review) + .build()); + }); + + if (reviewCount == 0) { + reviewRepository.save(review); + } else { + throw new GlobalRuntimeException(ReviewErrorCode.ALREADY_HAVE_A_REVIEW); + } + + reviewMenuMapRepository.saveAll(reviewMenuMapList); + + List images = s3Uploader.saveFile(files); + + if (!images.isEmpty()) { + Point point = pointLogRepository.findByMemberId(memberId); + + // add point + paidPoints(point, PointReview.RESTAURANT_POINT, review); + + List reviewImages = saveImageFiles(review, images); + reviewImageRepository.saveAll(reviewImages); + } + } + + private Restaurant findRestaurantById(Long restaurantId) { + Optional restaurantOptional = restaurantRepository.findById(restaurantId); + + return restaurantOptional.orElseThrow(() -> new GlobalRuntimeException(ReviewErrorCode.RESTAURANT_NOT_EXITST)); + } + + private Member findMemberById(Long memberid) { + Optional memberOptional = memberRepository.findById(memberid); + + return memberOptional.orElseThrow(() -> new GlobalRuntimeException(ReviewErrorCode.MEMBER_NOT_EXIST)); + } + + private Building getBuildingByMember(Member member) { + Company company = checkExistMemberById(member.getId()).getCompany(); + + return company.getBuilding(); + } + + private Member checkExistMemberById(Long memberId) { + Optional memberOptional = memberRepository.findById(memberId); + + return memberOptional.orElseThrow(() -> new GlobalRuntimeException(ReviewErrorCode.MEMBER_NOT_EXIST)); + } + + + private List saveImageFiles(Review review, List images) { + List reviewImages = images.stream().map(image -> + ReviewImage.builder() + .review(review) + .url(image) + .build() + ).collect(Collectors.toList()); + + return reviewImages; + } + + public List findCalendarList(Long memberId, String year, String month) { + + checkExistMemberById(memberId); + + return reviewProjectionRepository.findCalendarListByYearAndMonth(year, month); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) + public void paidPoints(Point point, PointReview pointReview, Review review) { + + point.plusPoint(pointReview.getAmount()); + + PointLog pointLog = PointLog.builder() + .point(point) + .review(review) + .code(pointReview.getPointCode()) + .amount(pointReview.getAmount()) + .build(); + + pointLogRepository.save(pointLog); + } + + @Transactional(readOnly = true) + public List findAllReviewDetailList(Long memberId, Integer year, Integer month) { + + LocalDateTime requestTime = LocalDateTime.of(year, month, 1, 0, 0, 0); + + DateRange requestDateRange = dateFactory.getMonthRangeOf(requestTime); + List allReviewDetailDTOS = reviewProjectionRepository.findAllReviewDetailBetween( + memberId, requestDateRange.getStartDate(), requestDateRange.getEndDate()); + + return allReviewDetailDTOS.stream() + .map(detailDTO -> ReviewResponse.DetailListDTO.valueOf(detailDTO, imageSeparator)) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/livable/server/visitation/controller/VisitationController.java b/src/main/java/com/livable/server/visitation/controller/VisitationController.java new file mode 100644 index 00000000..a5b52057 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/controller/VisitationController.java @@ -0,0 +1,82 @@ +package com.livable.server.visitation.controller; + +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.Actor; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.LoginActor; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.service.VisitationFacadeService; +import com.livable.server.visitation.dto.VisitationRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/visitation") +public class VisitationController { + + private final VisitationFacadeService visitationFacadeService; + + @GetMapping + public ResponseEntity> findVisitationDetailInformation(@LoginActor Actor actor) { + + JwtTokenProvider.checkVisitorToken(actor); + + Long visitorId = actor.getId(); + VisitationResponse.DetailInformationDto detailInformationDto = + visitationFacadeService.findVisitationDetailInformation(visitorId); + + return ApiResponse.success(detailInformationDto, HttpStatus.OK); + } + + @GetMapping("/qr") + public ResponseEntity> createQrCode(@LoginActor Actor actor) { + + JwtTokenProvider.checkVisitorToken(actor); + + Long visitorId = actor.getId(); + VisitationResponse.Base64QrCode qrCode = visitationFacadeService.createQrCode(visitorId); + + return ApiResponse.success(qrCode, HttpStatus.OK); + } + + @PostMapping("/qr") + public ResponseEntity> validateQrCode( + @RequestBody @Valid VisitationRequest.ValidateQrCodeDto validateQrCodeDto, + @LoginActor Actor actor + ) { + JwtTokenProvider.checkVisitorToken(actor); + + visitationFacadeService.validateQrCode(validateQrCodeDto.getQr(), actor.getId()); + + return ApiResponse.success(HttpStatus.OK); + } + + @GetMapping("/parking") + public ResponseEntity> findCarNumber(@LoginActor Actor actor) { + + JwtTokenProvider.checkVisitorToken(actor); + + Long visitorId = actor.getId(); + VisitationResponse.CarNumber result = visitationFacadeService.findCarNumber(visitorId); + + return ApiResponse.success(result, HttpStatus.OK); + } + + @PostMapping("/parking") + public ResponseEntity> registerParking( + @RequestBody @Valid VisitationRequest.RegisterParkingDto registerParkingDto, @LoginActor Actor actor + ) { + + JwtTokenProvider.checkVisitorToken(actor); + + Long visitorId = actor.getId(); + visitationFacadeService.registerParking(visitorId, registerParkingDto.getCarNumber()); + + return ApiResponse.success(HttpStatus.CREATED); + } +} diff --git a/src/main/java/com/livable/server/visitation/domain/PlaceType.java b/src/main/java/com/livable/server/visitation/domain/PlaceType.java new file mode 100644 index 00000000..50243fbd --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/PlaceType.java @@ -0,0 +1,7 @@ +package com.livable.server.visitation.domain; + +public enum PlaceType { + + COMPANY, + COMMON_PLACE; +} diff --git a/src/main/java/com/livable/server/visitation/domain/QrCodeDecoder.java b/src/main/java/com/livable/server/visitation/domain/QrCodeDecoder.java new file mode 100644 index 00000000..2029a801 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/QrCodeDecoder.java @@ -0,0 +1,68 @@ +package com.livable.server.visitation.domain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.zxing.*; +import com.google.zxing.client.j2se.BufferedImageLuminanceSource; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeReader; +import com.livable.server.core.exception.GlobalRuntimeException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.EnumMap; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Component +public class QrCodeDecoder { + private static final String DEFAULT_CHARSET = "UTF-8"; + + private final ObjectMapper objectMapper; + + public QrPayload getQrPayload(final String base64QrCode) { + + Map hints = getDecodeHints(); + String decodeQrContent = getDecodeQrContent(base64QrCode, hints); + + try { + return objectMapper.readValue(decodeQrContent, QrPayload.class); + } catch (JsonProcessingException e) { + log.error("QrCodeManager.getQrPayload", e); + throw new GlobalRuntimeException(VisitationErrorCode.OBJECTMAPPER); + } + } + + private Map getDecodeHints() { + return new EnumMap<>(DecodeHintType.class) {{ + put(DecodeHintType.CHARACTER_SET, DEFAULT_CHARSET); + }}; + } + + private String getDecodeQrContent(final String base64QrCode, final Map hints) { + + Base64.Decoder decoder = Base64.getDecoder(); + byte[] imageBytes = decoder.decode(base64QrCode); + + try { + BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(imageBytes)); + BinaryBitmap binaryBitmap = new BinaryBitmap(new HybridBinarizer(new BufferedImageLuminanceSource(bufferedImage))); + Result decode = new QRCodeReader().decode(binaryBitmap, hints); + + return decode.getText(); + } catch (IOException e) { + log.error("QrCodeManager.getDecodeQrContent", e); + throw new GlobalRuntimeException(VisitationErrorCode.IO); + } catch (ChecksumException | NotFoundException | FormatException e) { + log.error("QrCodeManager.getDecodeQrContent", e); + throw new GlobalRuntimeException(VisitationErrorCode.QR_DECODE); + } + } +} diff --git a/src/main/java/com/livable/server/visitation/domain/QrCodeEncoder.java b/src/main/java/com/livable/server/visitation/domain/QrCodeEncoder.java new file mode 100644 index 00000000..321287e0 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/QrCodeEncoder.java @@ -0,0 +1,87 @@ +package com.livable.server.visitation.domain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.livable.server.core.exception.GlobalRuntimeException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Component +public class QrCodeEncoder { + + private static final int DEFAULT_WIDTH = 170; + private static final int DEFAULT_HEIGHT = 170; + private static final String EXPIRATION_START_DATE_KEY = "startDate"; + private static final String EXPIRATION_END_DATE_KEY = "endDate"; + private static final String DEFAULT_CHARSET = "UTF-8"; + private static final String DEFAULT_FORMAT = "png"; + + private final ObjectMapper objectMapper; + + public BufferedImage createQrCodeBufferdImage(final LocalDateTime startDateTime, final LocalDateTime endDateTime) { + try { + + HashMap expirationPeriodMap = getExpirationPeriodMap(startDateTime, endDateTime); + String contents = objectMapper.registerModule(new JavaTimeModule()).writeValueAsString(expirationPeriodMap); + + Map encodeHints = getEncodeHints(); + + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + + BitMatrix bitMatrix = qrCodeWriter.encode(contents, BarcodeFormat.QR_CODE, DEFAULT_WIDTH, DEFAULT_HEIGHT, encodeHints); + + return MatrixToImageWriter.toBufferedImage(bitMatrix); + } catch (JsonProcessingException | WriterException e) { + throw new RuntimeException(e); + } + } + + private HashMap getExpirationPeriodMap(final LocalDateTime startDate, final LocalDateTime endDate) { + return new HashMap<>() {{ + put(EXPIRATION_START_DATE_KEY, startDate); + put(EXPIRATION_END_DATE_KEY, endDate); + }}; + } + + private Map getEncodeHints() { + return new EnumMap<>(EncodeHintType.class) {{ + put(EncodeHintType.CHARACTER_SET, DEFAULT_CHARSET); + }}; + } + + public String encodeQrcodeToBase64(final BufferedImage bufferedImage) { + try { + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + ImageIO.write(bufferedImage, DEFAULT_FORMAT, outputStream); + + byte[] imageBytes = outputStream.toByteArray(); + return Base64.getEncoder().encodeToString(imageBytes); + + } catch (IOException e) { + log.error("QrCodeManager.encodeQrcodeToBase64", e); + throw new GlobalRuntimeException(VisitationErrorCode.IO); + } + } +} diff --git a/src/main/java/com/livable/server/visitation/domain/QrCodeManager.java b/src/main/java/com/livable/server/visitation/domain/QrCodeManager.java new file mode 100644 index 00000000..c1eab816 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/QrCodeManager.java @@ -0,0 +1,46 @@ +package com.livable.server.visitation.domain; + +import com.livable.server.core.exception.GlobalRuntimeException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.awt.image.BufferedImage; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor +@Component +public class QrCodeManager { + + private static final int DEFAULT_QR_CODE_COLOR = 0xFFFFFFFF; + private static final int DEFAULT_BACKGROUND_COLOR = 0xFF2563EA; + + private final QrCodeEncoder qrCodeEncoder; + private final QrCodeDecoder qrCodeDecoder; + + public String createQrCode(final LocalDateTime startDate, final LocalDateTime endDate) { + + validatePeriod(startDate, endDate); + + BufferedImage qrCodeBufferdImage = qrCodeEncoder.createQrCodeBufferdImage(startDate, endDate); + + return qrCodeEncoder.encodeQrcodeToBase64(qrCodeBufferdImage); + } + + private void validatePeriod(final LocalDateTime startDate, final LocalDateTime endDate) { + if (startDate.isAfter(endDate) || endDate.isBefore(startDate)) { + throw new GlobalRuntimeException(VisitationErrorCode.INVALID_PERIOD); + } + + if (!(startDate.isBefore(LocalDateTime.now()) && endDate.isAfter(LocalDateTime.now()))) { + throw new GlobalRuntimeException(VisitationErrorCode.INVALID_QR_PERIOD); + } + } + + public void validateQrCode(final String base64QrCode) { + + QrPayload qrPayload = qrCodeDecoder.getQrPayload(base64QrCode); + validatePeriod(qrPayload.getStartDate(), qrPayload.getEndDate()); + } +} diff --git a/src/main/java/com/livable/server/visitation/domain/QrPayload.java b/src/main/java/com/livable/server/visitation/domain/QrPayload.java new file mode 100644 index 00000000..e14f614a --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/QrPayload.java @@ -0,0 +1,12 @@ +package com.livable.server.visitation.domain; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class QrPayload { + + private LocalDateTime startDate; + private LocalDateTime endDate; +} diff --git a/src/main/java/com/livable/server/visitation/domain/VisitationErrorCode.java b/src/main/java/com/livable/server/visitation/domain/VisitationErrorCode.java new file mode 100644 index 00000000..a66bf63e --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/VisitationErrorCode.java @@ -0,0 +1,24 @@ +package com.livable.server.visitation.domain; + +import com.livable.server.core.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum VisitationErrorCode implements ErrorCode { + + OBJECTMAPPER(HttpStatus.INTERNAL_SERVER_ERROR, "직렬화 과정에서 알 수 없는 에러가 발생했습니다."), + QR_ENCODE(HttpStatus.INTERNAL_SERVER_ERROR, "QR코드 생성 중 알 수 없는 에러가 발생했습니다."), + QR_DECODE(HttpStatus.INTERNAL_SERVER_ERROR, "QR코드를 푸는 과정에서 알 수 없는 에러가 발생했습니다."), + IO(HttpStatus.INTERNAL_SERVER_ERROR, "I/O를 진행하는 과정에서 알 수 없는 에러가 발생했습니다."), + INVALID_QR_PERIOD(HttpStatus.BAD_REQUEST, "QR을 생성할 수 없는 시간입니다."), + INVALID_PERIOD(HttpStatus.BAD_REQUEST, "시작 및 종료시간이 올바르지 않습니다."), + NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 정보입니다."), + ALREADY_REGISTER_PARKING(HttpStatus.BAD_REQUEST, "이미 주차 등록을 완료하였습니다."); + + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/livable/server/visitation/domain/VisitationValidationMessage.java b/src/main/java/com/livable/server/visitation/domain/VisitationValidationMessage.java new file mode 100644 index 00000000..c9af105c --- /dev/null +++ b/src/main/java/com/livable/server/visitation/domain/VisitationValidationMessage.java @@ -0,0 +1,7 @@ +package com.livable.server.visitation.domain; + +public interface VisitationValidationMessage { + + String INVALID_CAR_NUMBER = "차량 번호를 올바르게 입력해 주세요."; + String NOT_BLANK = "값을 입력해주세요"; +} diff --git a/src/main/java/com/livable/server/visitation/dto/VisitationRequest.java b/src/main/java/com/livable/server/visitation/dto/VisitationRequest.java new file mode 100644 index 00000000..ec0afc59 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/dto/VisitationRequest.java @@ -0,0 +1,31 @@ +package com.livable.server.visitation.dto; + +import com.livable.server.visitation.domain.VisitationValidationMessage; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class VisitationRequest { + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class ValidateQrCodeDto { + + @NotBlank(message = VisitationValidationMessage.NOT_BLANK) + private String qr; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class RegisterParkingDto { + + @NotBlank(message = VisitationValidationMessage.NOT_BLANK) + @Pattern(regexp = "^\\d{2,3}[가-힣]{1}\\d{4}$", message = VisitationValidationMessage.INVALID_CAR_NUMBER) + private String carNumber; + } +} diff --git a/src/main/java/com/livable/server/visitation/dto/VisitationResponse.java b/src/main/java/com/livable/server/visitation/dto/VisitationResponse.java new file mode 100644 index 00000000..15e5dd49 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/dto/VisitationResponse.java @@ -0,0 +1,117 @@ +package com.livable.server.visitation.dto; + +import com.livable.server.invitation.dto.InvitationDetailTimeDto; +import com.livable.server.visitation.domain.PlaceType; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class VisitationResponse { + + @Getter + @NoArgsConstructor + public static class Base64QrCode { + + private String qr; + + public static Base64QrCode of(String base64QrCode) { + Base64QrCode code = new Base64QrCode(); + code.qr = base64QrCode; + return code; + } + } + + @Getter + @NoArgsConstructor + public static class CarNumber { + + private Long visitorId; + private String carNumber; + + public static CarNumber of(Long visitorId, String carNumber) { + CarNumber carNumberDto = new CarNumber(); + carNumberDto.visitorId = visitorId; + carNumberDto.carNumber = carNumber; + + return carNumberDto; + } + } + + @Getter + @NoArgsConstructor + public static class DetailInformationDto { + + private LocalDate invitationStartDate; + private LocalTime invitationStartTime; + private LocalDate invitationEndDate; + private LocalTime invitationEndTime; + private String invitationBuildingName; + private String invitationOfficeName; + + private String buildingRepresentativeImageUrl; + private String buildingName; + private String buildingAddress; + private String buildingParkingCostInformation; + private String buildingScale; + + private PlaceType placeType; + private String invitationTip; + + private String hostName; + private String hostCompanyName; + private String hostContact; + private String hostBusinessCardImageUrl; + + public DetailInformationDto(LocalDate invitationStartDate, LocalTime invitationStartTime, LocalDate invitationEndDate, LocalTime invitationEndTime, String invitationBuildingName, String invitationOfficeName, String buildingRepresentativeImageUrl, String buildingName, String buildingAddress, String buildingParkingCostInformation, String buildingScale, String placeType, String invitationTip, String hostName, String hostCompanyName, String hostContact, String hostBusinessCardImageUrl) { + this.invitationStartDate = invitationStartDate; + this.invitationStartTime = invitationStartTime; + this.invitationEndDate = invitationEndDate; + this.invitationEndTime = invitationEndTime; + this.invitationBuildingName = invitationBuildingName; + this.invitationOfficeName = invitationOfficeName; + this.buildingRepresentativeImageUrl = buildingRepresentativeImageUrl; + this.buildingName = buildingName; + this.buildingAddress = buildingAddress; + this.buildingParkingCostInformation = buildingParkingCostInformation; + this.buildingScale = buildingScale; + this.placeType = PlaceType.valueOf(placeType); + this.invitationTip = invitationTip; + this.hostName = hostName; + this.hostCompanyName = hostCompanyName; + this.hostContact = hostContact; + this.hostBusinessCardImageUrl = hostBusinessCardImageUrl; + } + } + + + @Getter + @Builder + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor(access = AccessLevel.PROTECTED) + public static class InvitationTimeDto { + private LocalDate startDate; + private LocalDate endDate; + private LocalTime startTime; + private LocalTime endTime; + + public LocalDateTime getStartDateTime() { + return LocalDateTime.of(startDate, startTime); + } + + public LocalDateTime getEndDateTime() { + return LocalDateTime.of(endDate, endTime); + } + + public static InvitationTimeDto from(final InvitationDetailTimeDto invitationDetailTimeDto) { + return InvitationTimeDto.builder() + .startTime(invitationDetailTimeDto.getStartTime()) + .endTime(invitationDetailTimeDto.getEndTime()) + .startDate(invitationDetailTimeDto.getStartDate()) + .endDate(invitationDetailTimeDto.getEndDate()) + .build(); + } + } +} diff --git a/src/main/java/com/livable/server/visitation/repository/ParkingLogRepository.java b/src/main/java/com/livable/server/visitation/repository/ParkingLogRepository.java new file mode 100644 index 00000000..cde66e28 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/repository/ParkingLogRepository.java @@ -0,0 +1,24 @@ +package com.livable.server.visitation.repository; + +import com.livable.server.entity.ParkingLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ParkingLogRepository extends JpaRepository { + + @Query("select p from ParkingLog p" + + " where p.visitor.id = :visitorId") + Optional findParkingLogByVisitorId(@Param("visitorId") final Long visitorId); + + @Modifying + @Query("delete from ParkingLog p where p.visitor.id in :visitorIds") + void deleteByVisitorIdsIn(@Param("visitorIds") List visitorIds); + + @Query("select p.carNumber from ParkingLog p where p.visitor.id in :visitorId") + Optional findCarNumberByVisitorId(@Param("visitorId") final Long visitorId); +} diff --git a/src/main/java/com/livable/server/visitation/repository/VisitorCustomRepository.java b/src/main/java/com/livable/server/visitation/repository/VisitorCustomRepository.java new file mode 100644 index 00000000..d7742e14 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/repository/VisitorCustomRepository.java @@ -0,0 +1,12 @@ +package com.livable.server.visitation.repository; + +import com.livable.server.visitation.dto.VisitationResponse; + +import java.util.Optional; + +public interface VisitorCustomRepository { + + Optional findVisitationDetailInformationById(final Long visitorId); + + Optional findBuildingIdById(final Long visitorId); +} diff --git a/src/main/java/com/livable/server/visitation/repository/VisitorCustomRepositoryImpl.java b/src/main/java/com/livable/server/visitation/repository/VisitorCustomRepositoryImpl.java new file mode 100644 index 00000000..5c8e4b12 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/repository/VisitorCustomRepositoryImpl.java @@ -0,0 +1,79 @@ +package com.livable.server.visitation.repository; + +import com.livable.server.entity.*; +import com.livable.server.visitation.domain.PlaceType; +import com.livable.server.visitation.dto.VisitationResponse; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@RequiredArgsConstructor +public class VisitorCustomRepositoryImpl implements VisitorCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findVisitationDetailInformationById(final Long visitorId) { + final QInvitation invitation = QInvitation.invitation; + final QBuilding building = QBuilding.building; + final QCompany company = QCompany.company; + final QMember member = QMember.member; + final QVisitor visitor = QVisitor.visitor; + + JPAQuery query = queryFactory + .select(Projections.constructor(VisitationResponse.DetailInformationDto.class, + invitation.startDate, + invitation.startTime, + invitation.endDate, + invitation.endTime, + building.name, + invitation.officeName, + building.representativeImageUrl, + building.name, + building.address, + building.parkingCostInformation, + building.scale, + new CaseBuilder().when(invitation.officeName.contains("사무실")) + .then(PlaceType.COMPANY.name()) + .otherwise(PlaceType.COMMON_PLACE.name()), + invitation.description, + member.name, + company.name, + member.contact, + member.businessCardImageUrl + )) + .from(visitor) + .innerJoin(invitation).on(visitor.invitation.id.eq(invitation.id)) + .innerJoin(member).on(invitation.member.id.eq(member.id)) + .innerJoin(company).on(member.company.id.eq(company.id)) + .innerJoin(building).on(company.building.id.eq(building.id)) + .where(visitor.id.eq(visitorId)); + + return Optional.ofNullable(query.fetchJoin().fetchOne()); + } + + @Override + public Optional findBuildingIdById(Long visitorId) { + + final QBuilding building = QBuilding.building; + final QVisitor visitor = QVisitor.visitor; + final QInvitation invitation = QInvitation.invitation; + final QMember member = QMember.member; + final QCompany company = QCompany.company; + + JPAQuery query = queryFactory + .select(building.id) + .from(visitor) + .innerJoin(invitation).on(visitor.invitation.id.eq(invitation.id)) + .innerJoin(member).on(invitation.member.id.eq(member.id)) + .innerJoin(company).on(member.company.id.eq(company.id)) + .innerJoin(building).on(company.building.id.eq(building.id)) + .where(visitor.id.eq(visitorId)); + + return Optional.ofNullable(query.fetchJoin().fetchOne()); + } +} diff --git a/src/main/java/com/livable/server/visitation/repository/VisitorRepository.java b/src/main/java/com/livable/server/visitation/repository/VisitorRepository.java new file mode 100644 index 00000000..4cb878fa --- /dev/null +++ b/src/main/java/com/livable/server/visitation/repository/VisitorRepository.java @@ -0,0 +1,20 @@ +package com.livable.server.visitation.repository; + +import com.livable.server.entity.Invitation; +import com.livable.server.entity.Visitor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface VisitorRepository extends JpaRepository, VisitorCustomRepository { + List findVisitorsByInvitation(Invitation invitation); + + @Modifying + @Query("delete from Visitor v where v.id in :ids") + void deleteByIdsIn(@Param("ids") List ids); + + long countByInvitation(Invitation invitation); +} diff --git a/src/main/java/com/livable/server/visitation/service/ParkingLogService.java b/src/main/java/com/livable/server/visitation/service/ParkingLogService.java new file mode 100644 index 00000000..0e70bba4 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/service/ParkingLogService.java @@ -0,0 +1,30 @@ +package com.livable.server.visitation.service; + +import com.livable.server.entity.ParkingLog; +import com.livable.server.entity.Visitor; +import com.livable.server.visitation.repository.ParkingLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class ParkingLogService { + + private final ParkingLogRepository parkingLogRepository; + + public Optional findParkingLogByVisitorId(final Long visitorId) { + return parkingLogRepository.findParkingLogByVisitorId(visitorId); + } + + public Optional findCarNumberByVisitorId(final Long visitorId) { + return parkingLogRepository.findCarNumberByVisitorId(visitorId); + } + + public void registerParkingLog(final Visitor visitor, final String carNumber) { + ParkingLog parkingLog = ParkingLog.create(visitor, carNumber); + + parkingLogRepository.save(parkingLog); + } +} diff --git a/src/main/java/com/livable/server/visitation/service/VisitationFacadeService.java b/src/main/java/com/livable/server/visitation/service/VisitationFacadeService.java new file mode 100644 index 00000000..42a243a7 --- /dev/null +++ b/src/main/java/com/livable/server/visitation/service/VisitationFacadeService.java @@ -0,0 +1,61 @@ +package com.livable.server.visitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Visitor; +import com.livable.server.invitation.service.InvitationService; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class VisitationFacadeService { + + private final VisitationService visitationService; + private final InvitationService invitationService; + private final VisitorService visitorService; + private final ParkingLogService parkingLogService; + + public VisitationResponse.DetailInformationDto findVisitationDetailInformation(Long visitorId) { + return visitorService.findVisitationDetailInformation(visitorId); + } + + public VisitationResponse.Base64QrCode createQrCode(final Long visitorId) { + VisitationResponse.InvitationTimeDto invitationTime = invitationService.findInvitationTime(visitorId); + + String base64QrCode = visitationService.createQrCode( + invitationTime.getStartDateTime(), invitationTime.getEndDateTime() + ); + + return VisitationResponse.Base64QrCode.of(base64QrCode); + } + + @Transactional + public void validateQrCode(final String qr, final Long visitorId) { + visitationService.validateQrCode(qr); + visitorService.doEntrance(visitorId); + } + + @Transactional + public void registerParking(final Long visitorId, final String carNumber) { + validateDuplicationRegister(visitorId); + Visitor visitor = visitorService.findById(visitorId); + parkingLogService.registerParkingLog(visitor, carNumber); + } + + private void validateDuplicationRegister(final Long visitorId) { + if (parkingLogService.findParkingLogByVisitorId(visitorId).isPresent()) { + throw new GlobalRuntimeException(VisitationErrorCode.ALREADY_REGISTER_PARKING); + } + } + + public VisitationResponse.CarNumber findCarNumber(Long visitorId) { + String carNumber = parkingLogService.findCarNumberByVisitorId(visitorId) + .orElse(null); + + return VisitationResponse.CarNumber.of(visitorId, carNumber); + } +} diff --git a/src/main/java/com/livable/server/visitation/service/VisitationService.java b/src/main/java/com/livable/server/visitation/service/VisitationService.java new file mode 100644 index 00000000..5fb64b0b --- /dev/null +++ b/src/main/java/com/livable/server/visitation/service/VisitationService.java @@ -0,0 +1,22 @@ +package com.livable.server.visitation.service; + +import com.livable.server.visitation.domain.QrCodeManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@RequiredArgsConstructor +@Service +public class VisitationService { + + private final QrCodeManager qrCodeManager; + + public String createQrCode(final LocalDateTime startDate, final LocalDateTime endDate) { + return qrCodeManager.createQrCode(startDate, endDate); + } + + public void validateQrCode(final String qr) { + qrCodeManager.validateQrCode(qr); + } +} diff --git a/src/main/java/com/livable/server/visitation/service/VisitorService.java b/src/main/java/com/livable/server/visitation/service/VisitorService.java new file mode 100644 index 00000000..d9fe49ed --- /dev/null +++ b/src/main/java/com/livable/server/visitation/service/VisitorService.java @@ -0,0 +1,41 @@ +package com.livable.server.visitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Visitor; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.repository.VisitorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class VisitorService { + + private final VisitorRepository visitorRepository; + + public void doEntrance(final Long visitorId) { + + Visitor visitor = visitorRepository.findById(visitorId) + .orElseThrow(() -> new GlobalRuntimeException(VisitationErrorCode.NOT_FOUND)); + + visitor.entrance(); + } + + public Long findInvitationId(final Long visitorId) { + return visitorRepository.findById(visitorId) + .orElseThrow(() -> new GlobalRuntimeException(VisitationErrorCode.NOT_FOUND)) + .getInvitation() + .getId(); + } + + public Visitor findById(final Long visitorId) { + return visitorRepository.findById(visitorId) + .orElseThrow(() -> new GlobalRuntimeException(VisitationErrorCode.NOT_FOUND)); + } + + public VisitationResponse.DetailInformationDto findVisitationDetailInformation(final Long visitorId) { + return visitorRepository.findVisitationDetailInformationById(visitorId) + .orElseThrow(() -> new GlobalRuntimeException(VisitationErrorCode.NOT_FOUND)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..6096153a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,14 @@ +spring: + profiles: + default: dev + jpa: + properties: + hibernate: + default_batch_fetch_size: 100 + mvc: + path match: + matching-strategy: ant_path_matcher + +logging: + pattern: + dateformat: yyyy-MM-dd HH:mm:ss.SSSz,Asia/Seoul \ No newline at end of file diff --git a/src/test/java/com/livable/server/LivableServerApplicationTests.java b/src/test/java/com/livable/server/LivableServerApplicationTests.java new file mode 100644 index 00000000..ab1ac589 --- /dev/null +++ b/src/test/java/com/livable/server/LivableServerApplicationTests.java @@ -0,0 +1,15 @@ +package com.livable.server; + +import com.livable.server.core.util.TestProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@TestProperties +@SpringBootTest +class LivableServerApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/livable/server/core/util/ImageSeparatorTest.java b/src/test/java/com/livable/server/core/util/ImageSeparatorTest.java new file mode 100644 index 00000000..01559be4 --- /dev/null +++ b/src/test/java/com/livable/server/core/util/ImageSeparatorTest.java @@ -0,0 +1,62 @@ +package com.livable.server.core.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class ImageSeparatorTest { + + private ImageSeparator imageSeparator; + + @BeforeEach + void setUp() { + imageSeparator = new ImageSeparator(); + } + + @Test + void success_Test_GivenMultipleUrls() { + // Given + String images = "https://livable-final.s3.ap-northeast-2.amazonaws.com/%EB%A7%88%EB%9D%BC%ED%83%95%ED%83%95.jpg,https://livable-final.s3.ap-northeast-2.amazonaws.com/%EB%A7%88%EB%9D%BC%ED%83%95.jpg"; + + // When + List actual = imageSeparator.separateConcatenatedImages(images); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(2, actual.size()), + () -> Assertions.assertEquals("https://livable-final.s3.ap-northeast-2.amazonaws.com/%EB%A7%88%EB%9D%BC%ED%83%95%ED%83%95.jpg", actual.get(0)), + () -> Assertions.assertEquals("https://livable-final.s3.ap-northeast-2.amazonaws.com/%EB%A7%88%EB%9D%BC%ED%83%95.jpg", actual.get(1)) + ); + } + + @Test + void success_Test_GivenSingleUrls() { + // Given + String images = "https://livable-final.s3.ap-northeast-2.amazonaws.com/%EB%A7%88%EB%9D%BC%ED%83%95%ED%83%95.jpg"; + + // When + List actual = imageSeparator.separateConcatenatedImages(images); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(1, actual.size()), + () -> Assertions.assertEquals("https://livable-final.s3.ap-northeast-2.amazonaws.com/%EB%A7%88%EB%9D%BC%ED%83%95%ED%83%95.jpg", actual.get(0)) + ); + } + + @Test + void success_Test_GivenNull() { + // Given + String images = null; + + // When + List actual = imageSeparator.separateConcatenatedImages(images); + + // Then + Assertions.assertAll( + () -> Assertions.assertTrue(actual.isEmpty()) + ); + } +} diff --git a/src/test/java/com/livable/server/core/util/JwtTokenProviderTest.java b/src/test/java/com/livable/server/core/util/JwtTokenProviderTest.java new file mode 100644 index 00000000..ffcaed8c --- /dev/null +++ b/src/test/java/com/livable/server/core/util/JwtTokenProviderTest.java @@ -0,0 +1,48 @@ +package com.livable.server.core.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtTokenProviderTest { + + private JwtTokenProvider tokenProvider; + + @BeforeEach + void init() { + String testSecretKey = "di0xNUNaQDR1MWksaXM4MH5rdSZYLEM2I3dbR0ZQcWJUOVl5UFhmOV52cEROLmE0bCZheHdWLztCZHJoVjwz"; + tokenProvider = new JwtTokenProvider(testSecretKey); + } + + @DisplayName("[성공] Member 토큰 검증") + @Test + void validateMemberToken() { + // Given + Date expireDate = new Date(new Date().getTime() + 1000000); + String memberToken = tokenProvider.createActorToken(ActorType.MEMBER, 1L, expireDate); + + // When + boolean isValidToken = tokenProvider.isValidateToken(memberToken); + + // Then + assertThat(isValidToken).isTrue(); + } + + @DisplayName("[성공] Visitor 토큰 검증") + @Test + void validateVisitorToken() { + // Given + Date expireDate = new Date(new Date().getTime() + 1000000); + String visitorToken = tokenProvider.createActorToken(ActorType.VISITOR, 1L, expireDate); + + // When + boolean isValidToken = tokenProvider.isValidateToken(visitorToken); + + // Then + assertThat(isValidToken).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/core/util/StringToLocalDateConverterTest.java b/src/test/java/com/livable/server/core/util/StringToLocalDateConverterTest.java new file mode 100644 index 00000000..2416fcb1 --- /dev/null +++ b/src/test/java/com/livable/server/core/util/StringToLocalDateConverterTest.java @@ -0,0 +1,21 @@ +package com.livable.server.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class StringToLocalDateConverterTest { + + @DisplayName("StringToLocalDateConverter.convert 성공 테스트") + @Test + void convertSuccessTest() { + StringToLocalDateConverter converter = new StringToLocalDateConverter(); + + String query = "2023-04-23"; + + assertThat(LocalDate.of(2023, 4, 23)).isEqualTo(converter.convert(query)); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/core/util/StringToRestaurantCategoryConverterTest.java b/src/test/java/com/livable/server/core/util/StringToRestaurantCategoryConverterTest.java new file mode 100644 index 00000000..93088fd9 --- /dev/null +++ b/src/test/java/com/livable/server/core/util/StringToRestaurantCategoryConverterTest.java @@ -0,0 +1,56 @@ +package com.livable.server.core.util; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.RestaurantCategory; +import com.livable.server.restaurant.domain.RestaurantErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class StringToRestaurantCategoryConverterTest { + + public static StringToRestaurantCategoryConverter converter = new StringToRestaurantCategoryConverter(); + + @DisplayName("StringToRestaurantConverter 성공 테스트_1") + @CsvSource({"RESTAURANT", "restaurant", "Restaurant"}) + @ParameterizedTest(name = "[{index}] 입력문자: {0}") + void convertSuccessTest_RESTAURANT(String symbol) { + + // Given + // When + RestaurantCategory restaurantCategory = converter.convert(symbol); + + // Then + assertThat(restaurantCategory).isEqualTo(RestaurantCategory.RESTAURANT); + } + + @DisplayName("StringToRestaurantConverter 성공 테스트_2") + @CsvSource({"CAFE", "cafe", "Cafe"}) + @ParameterizedTest(name = "[{index}] 입력문자: {0}") + void convertSuccessTest_CAFE(String symbol) { + + // Given + // When + RestaurantCategory restaurantCategory = converter.convert(symbol); + + // Then + assertThat(restaurantCategory).isEqualTo(RestaurantCategory.CAFE); + } + + @DisplayName("StringToRestaurantConverter 실패 테스트") + @CsvSource({"CAFe", "caFe", "CAfe", "123", "zz", "restAuRant"}) + @ParameterizedTest(name = "[{index}] 입력문자: {0}") + void convertFailTest(String symbol) { + + // Given + // When + GlobalRuntimeException globalRuntimeException = + assertThrows(GlobalRuntimeException.class, () -> converter.convert(symbol)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(RestaurantErrorCode.NOT_FOUND_CATEGORY); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/core/util/TestConfig.java b/src/test/java/com/livable/server/core/util/TestConfig.java new file mode 100644 index 00000000..3c157473 --- /dev/null +++ b/src/test/java/com/livable/server/core/util/TestConfig.java @@ -0,0 +1,17 @@ +package com.livable.server.core.util; + + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestConfig { + + @Bean + public JwtTokenProvider jwtTokenProvider() { + String secretKey = "di0xNUNaQDR1MWksaXM4MH5rdSZYLEM2I3dbR0ZQcWJUOVl5UFhmOV52cEROLmE0bCZheHdWLztCZHJoVjwz"; + + return new JwtTokenProvider(secretKey); + } + +} diff --git a/src/test/java/com/livable/server/core/util/TestProperties.java b/src/test/java/com/livable/server/core/util/TestProperties.java new file mode 100644 index 00000000..63d9f5c1 --- /dev/null +++ b/src/test/java/com/livable/server/core/util/TestProperties.java @@ -0,0 +1,14 @@ +package com.livable.server.core.util; + +import org.springframework.test.context.TestPropertySource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@TestPropertySource(locations = "classpath:test.properties") +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface TestProperties { +} diff --git a/src/test/java/com/livable/server/entity/ParkingLogTest.java b/src/test/java/com/livable/server/entity/ParkingLogTest.java new file mode 100644 index 00000000..78b3883c --- /dev/null +++ b/src/test/java/com/livable/server/entity/ParkingLogTest.java @@ -0,0 +1,24 @@ +package com.livable.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class ParkingLogTest { + + @DisplayName("ParkingLog.create 성공 테스트") + @Test + void createSuccessTest() { + Visitor visitor = Visitor.builder() + .build(); + String carNumber = "12가3456"; + + ParkingLog parkingLog = ParkingLog.create(visitor, carNumber); + + assertThat(parkingLog.getVisitor()).isEqualTo(visitor); + assertThat(parkingLog.getCarNumber()).isEqualTo(carNumber); + } + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/entity/VisitorTest.java b/src/test/java/com/livable/server/entity/VisitorTest.java new file mode 100644 index 00000000..3d5fc4ad --- /dev/null +++ b/src/test/java/com/livable/server/entity/VisitorTest.java @@ -0,0 +1,42 @@ +package com.livable.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class VisitorTest { + + @DisplayName("Visitor.entrance 성공 테스트_1") + @Test + void entranceSuccessTest_1() { + // given + Visitor visitor = Visitor.builder() + .build(); + + // when + visitor.entrance(); + + // then + assertThat(visitor.getFirstVisitedTime()).isNotNull(); + } + + @DisplayName("Visitor.entrance 성공 테스트_1") + @Test + void entranceSuccessTest_2() { + // given + LocalDateTime now = LocalDateTime.now(); + Visitor visitor = Visitor.builder() + .firstVisitedTime(now) + .build(); + + // when + visitor.entrance(); + + // then + assertThat(visitor.getFirstVisitedTime()).isEqualTo(now); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/home/controller/HomeControllerTest.java b/src/test/java/com/livable/server/home/controller/HomeControllerTest.java new file mode 100644 index 00000000..3e6260b5 --- /dev/null +++ b/src/test/java/com/livable/server/home/controller/HomeControllerTest.java @@ -0,0 +1,134 @@ +package com.livable.server.home.controller; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.home.dto.HomeResponse.AccessCardDto; +import com.livable.server.home.dto.HomeResponse.BuildingInfoDto; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.member.dto.AccessCardProjection; +import com.livable.server.member.dto.BuildingInfoProjection; +import com.livable.server.member.service.MemberService; +import java.util.Date; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@Import(TestConfig.class) +@WebMvcTest(HomeController.class) +class HomeControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + JwtTokenProvider tokenProvider; + + @MockBean + private MemberService memberService; + + @DisplayName("SUCCESS : 홈 화면에 필요한 정보 응답 컨트롤러 테스트") + @Test + void getHomeInfoSuccess() throws Exception { + + // given + Long memberId = 1L; + String token = tokenProvider.createActorToken(ActorType.MEMBER, memberId, new Date(new Date().getTime() + 10000000)); + + BuildingInfoProjection buildingInfoProjection = new BuildingInfoProjection(1L, "테라 타워", true); + + given(memberService.getBuildingInfo(memberId)) + .willReturn(BuildingInfoDto.from(buildingInfoProjection)); + + // when & then + mockMvc.perform( + get("/api/home") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data['buildingId']").value(1)) + .andExpect(jsonPath("$.data['buildingName']").value("테라 타워")) + .andExpect(jsonPath("$.data['hasCafeteria']").value(true)); + } + + @DisplayName("FAILED : 홈 화면에 필요한 정보 응답 컨트롤러 테스트 - 조회 실패") + @Test + void getHomeInfoFailed() throws Exception { + + // given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + given(memberService.getBuildingInfo(anyLong())) + .willThrow(new GlobalRuntimeException(MemberErrorCode.BUILDING_INFO_NOT_EXIST)); + + // when & then + mockMvc.perform( + get("/api/home") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(MemberErrorCode.BUILDING_INFO_NOT_EXIST.getMessage())); + } + + @DisplayName("SUCCESS - 출입 카드 정보 응답 컨트롤러 테스트") + @Test + void getAccessCardSuccess() throws Exception { + + // given + String buildingName = "테라 타워"; + String employeeNumber = "123456"; + String companyName = "OFFICE 01"; + String floor = "1층"; + String roomNumber = "101호" ; + String employeeName = "TestUser"; + + Long memberId = 1L; + String token = tokenProvider.createActorToken(ActorType.MEMBER, memberId, new Date(new Date().getTime() + 10000000)); + + AccessCardProjection accessCardProjection = new AccessCardProjection(buildingName, employeeNumber, companyName, floor, roomNumber, employeeName); + + given(memberService.getAccessCardData(anyLong())) + .willReturn(AccessCardDto.from(accessCardProjection)); + + // when & then + mockMvc.perform(get("/api/access-card") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data['buildingName']").value(buildingName)) + .andExpect(jsonPath("$.data['employeeNumber']").value(employeeNumber)) + .andExpect(jsonPath("$.data['companyName']").value(companyName)) + .andExpect(jsonPath("$.data['floor']").value(floor)) + .andExpect(jsonPath("$.data['roomNumber']").value(roomNumber)) + .andExpect(jsonPath("$.data['employeeName']").value(employeeName)); + } + + @DisplayName("FAILED - 출입 카드 정보 응답 컨트롤러 테스트 - 조회 실패") + @Test + void getAccessCardFail() throws Exception { + + // given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + given(memberService.getAccessCardData(anyLong())) + .willThrow(new GlobalRuntimeException(MemberErrorCode.RETRIEVE_ACCESSCARD_FAILED)); + + // when & then + mockMvc.perform(get("/api/access-card") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(MemberErrorCode.RETRIEVE_ACCESSCARD_FAILED.getMessage())); + } + +} diff --git a/src/test/java/com/livable/server/invitation/controller/InvitationControllerTest.java b/src/test/java/com/livable/server/invitation/controller/InvitationControllerTest.java new file mode 100644 index 00000000..a5a7f213 --- /dev/null +++ b/src/test/java/com/livable/server/invitation/controller/InvitationControllerTest.java @@ -0,0 +1,500 @@ +package com.livable.server.invitation.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.response.ApiResponse; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.invitation.domain.InvitationErrorCode; +import com.livable.server.invitation.domain.InvitationValidationMessage; +import com.livable.server.invitation.dto.InvitationRequest; +import com.livable.server.invitation.dto.InvitationResponse; +import com.livable.server.invitation.service.InvitationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestConfig.class) +@WebMvcTest(InvitationController.class) +class InvitationControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + private JwtTokenProvider tokenProvider; + + @Autowired + ObjectMapper mapper; + + @MockBean + private InvitationService invitationService; + + @DisplayName("[성공] 예약 가능한 리스트 목록 - 정상 응답") + @Test + void getAvailablePlacesSuccess_01() throws Exception { + // Given + Long memberId = 1L; + given(invitationService.getAvailablePlaces(memberId)) + .willReturn(new ResponseEntity<>( + ApiResponse.Success.of( + InvitationResponse.AvailablePlacesDTO.builder() + .offices(createOfficeDTOList()) + .commonPlaces(createCommonPlaceDTOList()) + .build() + ), + HttpStatus.OK + )); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + get("/api/invitation/places/available") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data['offices']").isArray()) + .andExpect(jsonPath("$.data['offices'][0]['officeName']").value("사무실 A")) + .andExpect(jsonPath("$.data['commonPlaces']").isArray()) + .andExpect(jsonPath("$.data['commonPlaces'][0]['commonPlaceId']").value(1)); + } + + private List createOfficeDTOList() { + return List.of( + InvitationResponse.OfficeDTO.builder().officeName("사무실 A").build(), + InvitationResponse.OfficeDTO.builder().officeName("사무실 B").build(), + InvitationResponse.OfficeDTO.builder().officeName("사무실 C").build() + ); + } + + private List createCommonPlaceDTOList() { + return List.of( + InvitationResponse.CommonPlaceDTO.builder().commonPlaceId(1L).build(), + InvitationResponse.CommonPlaceDTO.builder().commonPlaceId(2L).build(), + InvitationResponse.CommonPlaceDTO.builder().commonPlaceId(3L).build() + ); + } + + @DisplayName("[실패] 예약 가능한 리스트 목록 - GlobalException 발생, 400") + @Test + void getAvailablePlacesFail_01() throws Exception { + // Given + given(invitationService.getAvailablePlaces(anyLong())) + .willThrow(new GlobalRuntimeException(InvitationErrorCode.MEMBER_NOT_EXIST)); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform(get("/api/invitation/places/available") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationErrorCode.MEMBER_NOT_EXIST.getMessage())); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (시작 날짜가 오늘 보다 과거일 경우)") + @Test + void createInvitationFail_01() throws Exception { + // Given + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().minusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(List.of( + InvitationRequest.VisitorCreateDTO.builder() + .name("홍길동") + .contact("01012341234") + .build() + )) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.REQUIRED_FUTURE_DATE)); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (방문자가 한 명도 없는 경우)") + @Test + void createInvitationFail_02() throws Exception { + // Given + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(List.of()) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.REQUIRED_VISITOR_COUNT)); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (방문자가 31명인 경우)") + @Test + void createInvitationFail_03() throws Exception { + // Given + List visitors = new ArrayList<>(); + for (int i = 0; i < 31; i++) { + visitors.add(InvitationRequest.VisitorCreateDTO.builder().name("홍길동").contact("01012341234").build()); + } + + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(visitors) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.REQUIRED_VISITOR_COUNT)); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (방문자 이름이 영어인 경우)") + @Test + void createInvitationFail_04() throws Exception { + // Given + List visitors = new ArrayList<>(); + visitors.add(InvitationRequest.VisitorCreateDTO.builder().name("testName").contact("01012341234").build()); + + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(visitors) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.VISITOR_NAME_FORMAT)); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (방문자 이름이 한 글자인 경우)") + @Test + void createInvitationFail_05() throws Exception { + // Given + List visitors = new ArrayList<>(); + visitors.add(InvitationRequest.VisitorCreateDTO.builder().name("김").contact("01012341234").build()); + + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(visitors) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.VISITOR_NAME_MIN_SIZE)); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (방문자 전화번호에 숫자 이외의 문자가 섞인 경우)") + @Test + void createInvitationFail_06() throws Exception { + // Given + List visitors = new ArrayList<>(); + visitors.add(InvitationRequest.VisitorCreateDTO.builder().name("홍길동").contact("01012341234as").build()); + + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(visitors) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.VISITOR_CONTACT_FORMAT)); + } + + @DisplayName("[실패] 초대장 저장 - 유효성 검사 실패 (방문자 전화번호 길이가 9자인 경우)") + @Test + void createInvitationFail_07() throws Exception { + // Given + List visitors = new ArrayList<>(); + visitors.add(InvitationRequest.VisitorCreateDTO.builder().name("홍길동").contact("010123412").build()); + + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .officeName("공용 라운지") + .startDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(LocalDate.now().plusDays(1L), LocalTime.of(10, 30, 0))) + .description("엘리베이터 앞에 있어요.") + .commonPlaceId(1L) + .visitors(visitors) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + post("/api/invitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.VISITOR_CONTACT_MIN_SIZE)); + } + + @DisplayName("[실패] 초대장 상세 조회 - 초대장 주인과 요청한 사람이 다른 경우") + @Test + void getInvitationFail_01() throws Exception { + // Given + Long invitationId = 1L; + given(invitationService.getInvitation(anyLong(), anyLong())) + .willThrow(new GlobalRuntimeException(InvitationErrorCode.INVALID_INVITATION_OWNER)); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + get("/api/invitation/{invitationId}", invitationId) + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value(InvitationErrorCode.INVALID_INVITATION_OWNER.getMessage())); + } + + @DisplayName("[성공] 초대장 상세 조회") + @Test + void getInvitationSuccess_01() throws Exception { + // Given + Long invitationId = 1L; + given(invitationService.getInvitation(anyLong(), anyLong())) + .willReturn(ApiResponse.success(InvitationResponse.DetailDTO.builder().build(), HttpStatus.OK)); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When & Then + mockMvc.perform( + get("/api/invitation/{invitationId}", invitationId) + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()); + } + + @DisplayName("[실패] 초대장 수정 - 시작 날짜, 종료 날짜가 요청 날짜보다 과거인 경우") + @Test + void updateInvitationFail_01() throws Exception { + // Given + LocalDateTime requestDate = LocalDateTime.now(); + Long invitationId = 1L; + Long commonPlaceId = 1L; + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .description("엘리베이터 타고 오른쪽으로 오면 바로 있습니다.") + .startDate(requestDate.minusDays(1L)) + .endDate(requestDate.minusDays(1L)) + .visitors(List.of()) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When + ResultActions resultActions = mockMvc.perform( + patch("/api/invitation/{invitationId}", invitationId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto))); + + // Then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.REQUIRED_FUTURE_DATE)); + } + + @DisplayName("[실패] 초대장 수정 - 방문자 리스트가 널인 경우") + @Test + void updateInvitationFail_02() throws Exception { + // Given + LocalDateTime requestDate = LocalDateTime.now(); + Long invitationId = 1L; + Long commonPlaceId = 1L; + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .description("엘리베이터 타고 오른쪽으로 오면 바로 있습니다.") + .startDate(requestDate.plusDays(1L)) + .endDate(requestDate.plusDays(1L)) + .visitors(null) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When + ResultActions resultActions = mockMvc.perform( + patch("/api/invitation/{invitationId}", invitationId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto))); + + // Then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.NOT_NULL)); + } + + @DisplayName("[실패] 초대장 수정 - 방문자 데이터가 널인 경우") + @Test + void updateInvitationFail_03() throws Exception { + // Given + LocalDateTime requestDate = LocalDateTime.now(); + Long invitationId = 1L; + Long commonPlaceId = 1L; + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .description("엘리베이터 타고 오른쪽으로 오면 바로 있습니다.") + .startDate(requestDate.plusDays(1L)) + .endDate(requestDate.plusDays(1L)) + .visitors(List.of( + InvitationRequest.VisitorForUpdateDTO.builder() + .name(null) + .contact(null) + .build() + )) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When + ResultActions resultActions = mockMvc.perform( + patch("/api/invitation/{invitationId}", invitationId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto))); + + // Then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(InvitationValidationMessage.NOT_NULL)); + } + + @DisplayName("[성공] 초대장 수정") + @Test + void updateInvitationSuccess_01() throws Exception { + // Given + LocalDateTime requestDate = LocalDateTime.now(); + Long invitationId = 1L; + Long commonPlaceId = 1L; + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .description("엘리베이터 타고 오른쪽으로 오면 바로 있습니다.") + .startDate(requestDate.plusDays(1L)) + .endDate(requestDate.plusDays(1L)) + .visitors(List.of( + InvitationRequest.VisitorForUpdateDTO.builder() + .name("홍프링") + .contact("01012341234") + .build() + )) + .build(); + + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 1000000)); + + // When + ResultActions resultActions = mockMvc.perform( + patch("/api/invitation/{invitationId}", invitationId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(dto))); + + // TODO: ApiResponse.success() WildCard 리펙토링 후 Mocking 코드 추가 -> 현재는 Stubbing 안돼서 기본값 반환됨. + + // Then + resultActions.andExpect(status().isOk()); + + verify(invitationService, times(1)) + .updateInvitation(anyLong(), any(InvitationRequest.UpdateDTO.class), anyLong()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/invitation/controller/InvitationValidationControllerTest.java b/src/test/java/com/livable/server/invitation/controller/InvitationValidationControllerTest.java new file mode 100644 index 00000000..83f44b8d --- /dev/null +++ b/src/test/java/com/livable/server/invitation/controller/InvitationValidationControllerTest.java @@ -0,0 +1,53 @@ +package com.livable.server.invitation.controller; + +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.invitation.service.InvitationValidationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Date; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestConfig.class) +@WebMvcTest(InvitationValidationController.class) +class InvitationValidationControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + JwtTokenProvider tokenProvider; + + @MockBean + InvitationValidationService invitationValidationService; + + @DisplayName("[성공] 방문자 Callback - 정상 응답") + @Test + void validateVisitorSuccess_01() throws Exception { + // Given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(Long.MAX_VALUE)); + + // When + ResultActions resultActions = mockMvc + .perform( + get("/api/invitation/callback") + .param("token", token) + ); + + // Then + resultActions + .andExpect(status().isFound()) + .andExpect(header().string("Authorization", "Bearer " + token)); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/invitation/repository/InvitationRepositoryTest.java b/src/test/java/com/livable/server/invitation/repository/InvitationRepositoryTest.java new file mode 100644 index 00000000..fdbff34f --- /dev/null +++ b/src/test/java/com/livable/server/invitation/repository/InvitationRepositoryTest.java @@ -0,0 +1,114 @@ +package com.livable.server.invitation.repository; + +import com.livable.server.core.config.QueryDslConfig; +import com.livable.server.entity.*; +import com.livable.server.invitation.dto.InvitationDetailTimeDto; +import com.livable.server.visitation.repository.VisitorRepository; +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.context.annotation.Import; + +import javax.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DataJpaTest +@Import(QueryDslConfig.class) +class InvitationRepositoryTest { + + public static final LocalDate START_DATE = LocalDate.now(); + public static final LocalTime START_TIME = LocalTime.of(1, 10); + public static final LocalTime END_TIME = LocalTime.of(1, 20); + public static final LocalDate END_DATE = LocalDate.now(); + + @Autowired + EntityManager entityManager; + + @Autowired + InvitationRepository invitationRepository; + + @Autowired + VisitorRepository visitorRepository; + + @BeforeEach + void dataInit() { + Building building = Building.builder() + .name("63빌딩") + .scale("지하 3층, 지상 63층") + .representativeImageUrl("./thumbnailImage.jpg") + .endTime(LocalTime.of(10, 30)) + .startTime(LocalTime.of(18, 30)) + .parkingCostInformation("10분당 1000원") + .longitude("10.10.10.10") + .latitude("123.123.123") + .hasCafeteria(false) + .address("서울시 강남구 서초대로 61길 7, 392") + .subwayStation("석촌역") + .build(); + + entityManager.persist(building); + + Company company = Company.builder() + .name("패스트캠퍼스") + .building(building) + .build(); + + entityManager.persist(company); + + Member member = Member.builder() + .company(company) + .contact("01012345678") + .name("김훈섭") + .email("test@naver.com") + .employeeNumber("9q0mavfdmmpoaskp") + .profileImageUrl("./profileImageUrl") + .password("1234") + .role(Role.USER) + .build(); + + entityManager.persist(member); + + Invitation invitation = Invitation.builder() + .member(member) + .endDate(END_DATE) + .startDate(START_DATE) + .startTime(START_TIME) + .endTime(END_TIME) + .description("알아서 와") + .purpose("interview") + .officeName("패스트캠퍼스 사무실") + .build(); + + entityManager.persist(invitation); + + Visitor visitor = Visitor.builder() + .invitation(invitation) + .name("최태윤") + .contact("01034567811") + .build(); + + entityManager.persist(visitor); + } + + @DisplayName("InvitationRepository.findInvitationDetailTimeByVisitorId 쿼리 성공 테스트") + @Test + void findInvitationDetailTimeByVisitorIdSuccessTest() { + + Visitor visitor = visitorRepository.findAll().get(0); + InvitationDetailTimeDto invitationDetailTimeDto = invitationRepository.findInvitationDetailTimeByVisitorId(visitor.getId()) + .get(); + + assertAll( + () -> assertThat(START_TIME).isEqualTo(invitationDetailTimeDto.getStartTime()), + () -> assertThat(END_TIME).isEqualTo(invitationDetailTimeDto.getEndTime()), + () -> assertThat(START_DATE).isEqualTo(invitationDetailTimeDto.getStartDate()), + () -> assertThat(END_DATE).isEqualTo(invitationDetailTimeDto.getEndDate()) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/invitation/service/InvitationServiceTest.java b/src/test/java/com/livable/server/invitation/service/InvitationServiceTest.java new file mode 100644 index 00000000..16b1f9e5 --- /dev/null +++ b/src/test/java/com/livable/server/invitation/service/InvitationServiceTest.java @@ -0,0 +1,993 @@ +package com.livable.server.invitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.response.ApiResponse.Success; +import com.livable.server.entity.*; +import com.livable.server.invitation.domain.InvitationErrorCode; +import com.livable.server.invitation.dto.InvitationRequest; +import com.livable.server.invitation.dto.InvitationResponse; +import com.livable.server.invitation.dto.InvitationResponse.AvailablePlacesDTO; +import com.livable.server.invitation.repository.InvitationRepository; +import com.livable.server.invitation.repository.InvitationReservationMapRepository; +import com.livable.server.invitation.repository.OfficeRepository; +import com.livable.server.invitation.service.data.InvitationBasicData; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.reservation.repository.ReservationRepository; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.mock.MockInvitationDetailTimeDto; +import com.livable.server.visitation.repository.ParkingLogRepository; +import com.livable.server.visitation.repository.VisitorRepository; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class InvitationServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private OfficeRepository officeRepository; + + @Mock + private ReservationRepository reservationRepository; + + @Mock + private InvitationRepository invitationRepository; + + @Mock + private VisitorRepository visitorRepository; + + @Mock + private InvitationReservationMapRepository invitationReservationMapRepository; + + @Mock + private ParkingLogRepository parkingLogRepository; + + @InjectMocks + private InvitationService invitationService; + + @DisplayName("[실패] 예약 가능한 리스트 목록 - 존재하지 않는 Member (400)") + @Test + void getAvailablePlacesFailTest_01() { + // Given + Long memberId = -1L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.getAvailablePlaces(memberId)); + + assertThat(exception.getErrorCode().getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getErrorCode().getMessage()).isEqualTo(InvitationErrorCode.MEMBER_NOT_EXIST.getMessage()); + } + + @DisplayName("[성공] 예약 가능한 리스트 목록 - 예약 목록이 없는 경우") + @Test + void getAvailablePlacesSuccess_01() { + // Given + InvitationBasicData basicData = InvitationBasicData.getInstance(); + + given(memberRepository.findById(basicData.getMember().getId())).willReturn(Optional.of(basicData.getMember())); + given(officeRepository.findAllByCompanyId(basicData.getCompany().getId())).willReturn(basicData.getOffices()); + given(reservationRepository.findReservationsByCompanyId(basicData.getCompany().getId())).willReturn(List.of()); + + // When + ResponseEntity> result = invitationService + .getAvailablePlaces(basicData.getMember().getId()); + + AvailablePlacesDTO data = result.getBody().getData(); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(data.getOffices().size()).isEqualTo(3); + assertThat(data.getCommonPlaces().size()).isEqualTo(0); + } + + @DisplayName("[성공] 예약 가능한 리스트 목록 - 예약 목록이 있는 경우") + @Test + void getAvailablePlacesSuccess_02() { + // Given + InvitationBasicData basicData = InvitationBasicData.getInstance(); + + given(memberRepository.findById(basicData.getMember().getId())).willReturn(Optional.of(basicData.getMember())); + given(officeRepository.findAllByCompanyId(basicData.getCompany().getId())).willReturn(basicData.getOffices()); + given(reservationRepository.findReservationsByCompanyId(basicData.getCompany().getId())) + .willReturn(createReservations()); + + // When + ResponseEntity> result = invitationService + .getAvailablePlaces(basicData.getMember().getId()); + + AvailablePlacesDTO data = result.getBody().getData(); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(data.getOffices().size()).isEqualTo(3); + assertThat(data.getCommonPlaces().size()).isEqualTo(2); + } + + @DisplayName("[실패] 초대장 생성 - 면접 초대 인원 2명") + @Test + void createInvitationFail_01() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .visitors(List.of( + InvitationRequest.VisitorCreateDTO.builder().build(), + InvitationRequest.VisitorCreateDTO.builder().build() + )) + .build(); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_INTERVIEW_MAXIMUM_NUMBER); + } + + @DisplayName("[실패] 초대장 생성 - 존재하지 않는 Member") + @Test + void createInvitationFail_02() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.empty()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.MEMBER_NOT_EXIST); + } + + @DisplayName("[실패] 초대장 생성 - 종료 날짜가 시작 날짜보다 과거일 경우") + @Test + void createInvitationFail_03() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .startDate(LocalDateTime.of(2025, 10, 30, 0, 0, 0)) + .endDate(LocalDateTime.of(2025, 10, 29, 0, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_DATE); + } + + @DisplayName("[실패] 초대장 생성 - 종료 시간이 시작 시간과 같은 경우") + @Test + void createInvitationFail_04() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .startDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_TIME); + } + + @DisplayName("[실패] 초대장 생성 - 종료 시간이 시작 시간보다 과거인 경우") + @Test + void createInvitationFail_05() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .startDate(LocalDateTime.of(2025, 10, 30, 10, 30, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_TIME); + } + + @DisplayName("[실패] 초대장 생성 - 입력된 시간 범위의 예상 예약 개수와 실제 예약 개수가 다른 경우") + @Test + void createInvitationFail_06() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .commonPlaceId(1L) + .startDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 12, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + given(reservationRepository.findReservationsByCommonPlaceIdAndStartDateAndEndDate( + anyLong(), + any(LocalDateTime.class), + any(LocalDateTime.class) + )).willReturn(List.of()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_RESERVATION_COUNT); + } + + @DisplayName("[실패] 초대장 생성 - 시작 시간 단위가 0분 또는 30분이 아닌 경우") + @Test + void createInvitationFail_07() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .commonPlaceId(null) + .startDate(LocalDateTime.of(2025, 10, 30, 10, 28, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 12, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_TIME_UNIT); + } + + @DisplayName("[실패] 초대장 생성 - 종료 시간 단위가 0분 또는 30분이 아닌 경우") + @Test + void createInvitationFail_08() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .commonPlaceId(null) + .startDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 12, 14, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_TIME_UNIT); + } + + @DisplayName("[실패] 초대장 생성 - 시작, 종료 시간 단위가 0분 또는 30분이 아닌 경우") + @Test + void createInvitationFail_09() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .commonPlaceId(null) + .startDate(LocalDateTime.of(2025, 10, 30, 10, 28, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 12, 38, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.createInvitation(dto, memberId)); + + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_TIME_UNIT); + } + + @DisplayName("[성공] 초대장 생성 - 예약 장소가 있는 경우") + @Test + void createInvitationSuccess_01() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .commonPlaceId(1L) + .startDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 12, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + given(reservationRepository.findReservationsByCommonPlaceIdAndStartDateAndEndDate( + anyLong(), + any(LocalDateTime.class), + any(LocalDateTime.class) + )).willReturn(List.of( + Reservation.builder().build(), // 2025-10-30T10:00:00 + Reservation.builder().build(), // 2025-10-30T10:30:00 + Reservation.builder().build(), // 2025-10-30T11:00:00 + Reservation.builder().build() // 2025-10-30T11:30:00 + )); + given(invitationReservationMapRepository.save(any(InvitationReservationMap.class))) + .willReturn(InvitationReservationMap.builder().build()); + + // When & Then + ResponseEntity result = invitationService.createInvitation(dto, memberId); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @DisplayName("[성공] 초대장 생성 - 예약 장소가 없는 경우") + @Test + void createInvitationSuccess_02() { + // Given + Long memberId = 1L; + InvitationRequest.CreateDTO dto = InvitationRequest.CreateDTO.builder() + .purpose("interview") + .commonPlaceId(null) + .startDate(LocalDateTime.of(2025, 10, 30, 10, 0, 0)) + .endDate(LocalDateTime.of(2025, 10, 30, 12, 0, 0)) + .visitors(List.of(InvitationRequest.VisitorCreateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().build())); + given(invitationRepository.save(any(Invitation.class))).willReturn(Invitation.builder().build()); + given(visitorRepository.save(any(Visitor.class))).willReturn(Visitor.builder().build()); + + // When & Then + ResponseEntity result = invitationService.createInvitation(dto, memberId); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @DisplayName("[실패] 초대장 상세 조회 - 초대장 주인이 아닌 사람이 요청한 경우") + @Test + void getInvitationFail_01() { + // Given + Long invitationId = 1L; + Long memberId = 1L; + Member member = Member.builder().id(memberId + 1L).build(); + Invitation invitation = Invitation.builder().id(invitationId).member(member).build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.getInvitation(invitationId, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_INVITATION_OWNER); + } + + @DisplayName("[성공] 초대장 상세 조회") + @Test + void getInvitationFail_02() { + // Given + Long invitationId = 1L; + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + Invitation invitation = Invitation.builder().id(invitationId).member(member).build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + given(invitationRepository.findInvitationAndVisitorsByInvitationId(anyLong())) + .willReturn(any(InvitationResponse.DetailDTO.class)); + + // When + ResponseEntity> result + = invitationService.getInvitation(invitationId, memberId); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("[실패] 초대장 삭제 - 존재하지 않는 초대장인 경우") + @Test + void deleteInvitationFail_01() { + // Given + Long invitationId = 1L; + Long memberId = 1L; + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(Member.builder().id(memberId).build())); + given(invitationRepository.findById(anyLong())).willReturn(Optional.empty()); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.deleteInvitation(invitationId, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVITATION_NOT_EXIST); + } + + @DisplayName("[실패] 초대장 삭제 - 삭제 요청 날짜가 방문 날짜 이후인 경우") + @Test + void deleteInvitationFail_02() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateBeforeRequestDate = requestDate.minusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .startDate(dateBeforeRequestDate) + .member(member) + .build() + )); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.deleteInvitation(invitationId, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_DELETE_DATE); + } + + @DisplayName("[성공] 초대장 삭제") + @Test + void deleteInvitationFail_03() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(visitorRepository.findVisitorsByInvitation(any(Invitation.class))) + .willReturn(List.of(Visitor.builder().build())); + + // When + ResponseEntity result = invitationService.deleteInvitation(invitationId, memberId); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("[실패] 초대장 수정 - 초대장 방문 날짜가 요청 날짜보다 과거인 경우") + @Test + void updateInvitationFail_01() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateBeforeRequestDate = requestDate.minusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder().build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .startDate(dateBeforeRequestDate) + .member(member) + .build() + )); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_DELETE_DATE); + } + + @DisplayName("[실패] 초대장 수정 - 기존에 예약된 장소가 변경된 경우") + @Test + void updateInvitationFail_02() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())) + .willReturn(dto.getCommonPlaceId() + 1L); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION); + } + + @DisplayName("[실패] 초대장 수정 - 목적인 면접인데 추가 방문자가 있는 경우") + @Test + void updateInvitationFail_03() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors(List.of(InvitationRequest.VisitorForUpdateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("interview") + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(dto.getCommonPlaceId()); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_INTERVIEW_MAXIMUM_NUMBER); + } + + @DisplayName("[실패] 초대장 수정 - 최대 방문자 수를 넘어간 경우") + @Test + void updateInvitationFail_04() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors(List.of(InvitationRequest.VisitorForUpdateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(dto.getCommonPlaceId()); + given(visitorRepository.countByInvitation(any(Invitation.class))).willReturn(30L); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.INVALID_INVITATION_MAXIMUM_NUMBER); + } + + @DisplayName("[실패] 초대장 수정 - 기존 CommonPlaceId가 null 이고, 요청으로 들어온 commonPlaceId에 값이 있는 경우") + @Test + void updateInvitationFail_05() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors(List.of(InvitationRequest.VisitorForUpdateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(null); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION); + } + + @DisplayName("[실패] 초대장 수정 - 기존 CommonPlaceId에 값이 있고, 요청으로 들어온 commonPlaceId이 null 인 경우") + @Test + void updateInvitationFail_06() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(null) + .visitors(List.of(InvitationRequest.VisitorForUpdateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(1L); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION); + } + + @DisplayName("[실패] 초대장 수정 - 기존 CommonPlaceId에 값이 있고, 요청으로 들어온 commonPlaceId이 다른 값인 경우") + @Test + void updateInvitationFail_07() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors(List.of(InvitationRequest.VisitorForUpdateDTO.builder().build())) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(2L); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationService.updateInvitation(invitationId, dto, memberId)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(InvitationErrorCode.CAN_NOT_CHANGED_COMMON_PLACE_OF_INVITATION); + } + + @DisplayName("[성공] 초대장 수정 - 시간 변경 X, 인원 추가 X") + @Test + void updateInvitationSuccess_01() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors(List.of()) + .startDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(10, 30, 0))) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .endDate(dateAfterRequestDate) + .startTime(LocalTime.of(10, 0, 0)) + .endTime(LocalTime.of(10, 30, 0)) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(dto.getCommonPlaceId()); + given(visitorRepository.countByInvitation(any(Invitation.class))).willReturn(29L); + + // When + ResponseEntity result = invitationService.updateInvitation(invitationId, dto, memberId); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("[성공] 초대장 수정 - 시간 변경 X, 인원 추가 O") + @Test + void updateInvitationSuccess_02() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors( + List.of( + InvitationRequest.VisitorForUpdateDTO.builder().build(), + InvitationRequest.VisitorForUpdateDTO.builder().build(), + InvitationRequest.VisitorForUpdateDTO.builder().build() + ) + ) + .startDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(10, 0, 0))) + .endDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(10, 30, 0))) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())) + .willReturn(Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .endDate(dateAfterRequestDate) + .startTime(LocalTime.of(10, 0, 0)) + .endTime(LocalTime.of(10, 30, 0)) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(dto.getCommonPlaceId()); + given(visitorRepository.countByInvitation(any(Invitation.class))).willReturn(1L); + given(visitorRepository.saveAll(any())).willReturn(List.of(Visitor.builder().build())); + + // When + ResponseEntity result = invitationService.updateInvitation(invitationId, dto, memberId); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("[성공] 초대장 수정 - 시간 변경 O, 인원 추가 X") + @Test + void updateInvitationSuccess_03() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors(List.of()) + .startDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(11, 0, 0))) + .endDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(11, 30, 0))) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())).willReturn( + Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .endDate(dateAfterRequestDate) + .startTime(LocalTime.of(10, 0, 0)) + .endTime(LocalTime.of(10, 30, 0)) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(dto.getCommonPlaceId()); + given(visitorRepository.countByInvitation(any(Invitation.class))).willReturn(3L); + given(visitorRepository.findVisitorsByInvitation(any(Invitation.class))).willReturn( + List.of( + Visitor.builder().build(), + Visitor.builder().build(), + Visitor.builder().build() + )); + given(reservationRepository.findReservationsByCommonPlaceIdAndStartDateAndEndDate( + anyLong(), + any(LocalDateTime.class), + any(LocalDateTime.class) + )).willReturn(List.of(Reservation.builder().build())); + + // When + ResponseEntity result = invitationService.updateInvitation(invitationId, dto, memberId); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("[성공] 초대장 수정 - 시간 변경 O, 인원 추가 O") + @Test + void updateInvitationSuccess_04() { + // Given + LocalDate requestDate = LocalDate.now(); + LocalDate dateAfterRequestDate = requestDate.plusDays(1L); + Long invitationId = 1L; + Long memberId = 1L; + Long commonPlaceId = 1L; + Member member = Member.builder().id(memberId).build(); + InvitationRequest.UpdateDTO dto = InvitationRequest.UpdateDTO.builder() + .commonPlaceId(commonPlaceId) + .visitors( + List.of( + InvitationRequest.VisitorForUpdateDTO.builder().build(), + InvitationRequest.VisitorForUpdateDTO.builder().build(), + InvitationRequest.VisitorForUpdateDTO.builder().build() + ) + ) + .startDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(11, 0, 0))) + .endDate(LocalDateTime.of(dateAfterRequestDate, LocalTime.of(11, 30, 0))) + .build(); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(invitationRepository.findById(anyLong())).willReturn( + Optional.of(Invitation.builder() + .id(invitationId) + .purpose("회의") + .startDate(dateAfterRequestDate) + .endDate(dateAfterRequestDate) + .startTime(LocalTime.of(10, 0, 0)) + .endTime(LocalTime.of(10, 30, 0)) + .member(member) + .build() + )); + given(invitationRepository.getCommonPlaceIdByInvitationId(anyLong())).willReturn(dto.getCommonPlaceId()); + given(visitorRepository.countByInvitation(any(Invitation.class))).willReturn(3L); + given(visitorRepository.findVisitorsByInvitation(any(Invitation.class))).willReturn( + List.of( + Visitor.builder().build(), + Visitor.builder().build(), + Visitor.builder().build() + )); + given(reservationRepository.findReservationsByCommonPlaceIdAndStartDateAndEndDate( + anyLong(), + any(LocalDateTime.class), + any(LocalDateTime.class) + )).willReturn(List.of(Reservation.builder().build())); + + // When + ResponseEntity result = invitationService.updateInvitation(invitationId, dto, memberId); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private List createReservations() { + return new ArrayList<>(List.of( + new InvitationResponse.ReservationDTO( + 1L, + "1", + "101", + "공용 A" + ), + new InvitationResponse.ReservationDTO( + 2L, + "2", + "201", + "공용 B" + ) + )); + } + + @DisplayName("InvitationService.findInvitationTime 성공 테스트") + @Test + void findInvitationTimeSuccessTest() { + + // Given + MockInvitationDetailTimeDto mockInvitationDetailTimeDto = new MockInvitationDetailTimeDto(); + Invitation invitation = Invitation.builder() + .startTime(mockInvitationDetailTimeDto.getStartTime()) + .endTime(mockInvitationDetailTimeDto.getEndTime()) + .startDate(mockInvitationDetailTimeDto.getStartDate()) + .endDate(mockInvitationDetailTimeDto.getEndDate()) + .build(); + + given(invitationRepository.findInvitationDetailTimeByVisitorId(anyLong())) + .willReturn(Optional.of(mockInvitationDetailTimeDto)); + + // When + VisitationResponse.InvitationTimeDto invitationTime = invitationService.findInvitationTime(1L); + + // Then + then(invitationRepository).should(times(1)).findInvitationDetailTimeByVisitorId(anyLong()); + AssertionsForClassTypes.assertThat(invitationTime).usingRecursiveComparison().isEqualTo(invitation); + } + + @DisplayName("InvitationService.findInvitationTime 실패 테스트") + @Test + void findInvitationTimeFailTest() { + + // Given + given(invitationRepository.findInvitationDetailTimeByVisitorId(anyLong())).willReturn(Optional.empty()); + + // When + GlobalRuntimeException globalRuntimeException = assertThrows( + GlobalRuntimeException.class, () -> invitationService.findInvitationTime(anyLong()) + ); + + // Then + AssertionsForClassTypes.assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.NOT_FOUND); + then(invitationRepository).should(times(1)).findInvitationDetailTimeByVisitorId(anyLong()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/invitation/service/InvitationValidationServiceTest.java b/src/test/java/com/livable/server/invitation/service/InvitationValidationServiceTest.java new file mode 100644 index 00000000..bb081005 --- /dev/null +++ b/src/test/java/com/livable/server/invitation/service/InvitationValidationServiceTest.java @@ -0,0 +1,43 @@ +package com.livable.server.invitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.member.domain.MemberErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + + +@ExtendWith(MockitoExtension.class) +class InvitationValidationServiceTest { + + @Mock + JwtTokenProvider tokenProvider; + + @InjectMocks + InvitationValidationService invitationValidationService; + + @DisplayName("[실패] 방문자 callback - 잘못된 토큰으로 요청한 경우") + @Test + void validateVisitorFail_01() { + // Given + String token = "token"; + given(tokenProvider.parseClaims(anyString())) + .willThrow(new GlobalRuntimeException(MemberErrorCode.INVALID_TOKEN)); + + // When + GlobalRuntimeException exception = assertThrows(GlobalRuntimeException.class, + () -> invitationValidationService.validateVisitor(token)); + + // Then + assertThat(exception.getErrorCode()).isEqualTo(MemberErrorCode.INVALID_TOKEN); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/invitation/service/data/InvitationBasicData.java b/src/test/java/com/livable/server/invitation/service/data/InvitationBasicData.java new file mode 100644 index 00000000..1a7769ab --- /dev/null +++ b/src/test/java/com/livable/server/invitation/service/data/InvitationBasicData.java @@ -0,0 +1,63 @@ +package com.livable.server.invitation.service.data; + +import com.livable.server.entity.Building; +import com.livable.server.entity.Company; +import com.livable.server.entity.Member; +import com.livable.server.entity.Office; + +import java.util.List; + +public class InvitationBasicData { + + private static InvitationBasicData instance = null; + + public static InvitationBasicData getInstance() { + if (instance == null) { + instance = new InvitationBasicData(); + } + return instance; + } + + private final Building building; + private final Company company; + private final Member member; + private final List offices; + + private InvitationBasicData() { + this.building = Building.builder() + .id(1L) + .build(); + + this.company = Company.builder() + .id(1L) + .building(building) + .build(); + + this.member = Member.builder() + .id(1L) + .company(company) + .build(); + + this.offices = List.of( + Office.builder().id(1L).company(company).build(), + Office.builder().id(2L).company(company).build(), + Office.builder().id(3L).company(company).build() + ); + } + + public Building getBuilding() { + return building; + } + + public Company getCompany() { + return company; + } + + public Member getMember() { + return member; + } + + public List getOffices() { + return offices; + } +} diff --git a/src/test/java/com/livable/server/member/controller/MemberControllerTest.java b/src/test/java/com/livable/server/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..56eaab27 --- /dev/null +++ b/src/test/java/com/livable/server/member/controller/MemberControllerTest.java @@ -0,0 +1,66 @@ +package com.livable.server.member.controller; + +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.member.dto.MemberResponse; +import com.livable.server.member.service.MemberService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.Date; + +@Import(TestConfig.class) +@WebMvcTest(MemberController.class) +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider tokenProvider; + + @MockBean + private MemberService memberService; + + @Nested + @DisplayName("마이페이지 컨트롤러 단위 테스트") + class MyRestaurantReview { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/members"; + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + MemberResponse.MyPageDTO mockDTO = MemberResponse.MyPageDTO.builder() + .memberName("TestName") + .companyName("TestCompany") + .pointValance(200) + .build(); + + Mockito.when(memberService.getMyPageData(ArgumentMatchers.anyLong())) + .thenReturn(mockDTO); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .header("Authorization", "Bearer " + token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/member/service/MemberServiceTest.java b/src/test/java/com/livable/server/member/service/MemberServiceTest.java new file mode 100644 index 00000000..51e5d9aa --- /dev/null +++ b/src/test/java/com/livable/server/member/service/MemberServiceTest.java @@ -0,0 +1,174 @@ +package com.livable.server.member.service; + +import static com.livable.server.home.dto.HomeResponse.BuildingInfoDto; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.home.dto.HomeResponse.AccessCardDto; +import com.livable.server.member.dto.AccessCardProjection; +import com.livable.server.member.dto.BuildingInfoProjection; +import com.livable.server.member.dto.MemberResponse; +import com.livable.server.member.dto.MyPageProjection; +import com.livable.server.member.repository.MemberRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private MemberService memberService; + + @Nested + @DisplayName("마이페이지 서비스 단위 테스트") + class MyRestaurantReview { + + @DisplayName("성공") + @Test + void success_Test() { + // Given + Long memberId = 1L; + String memberName = "TestName"; + String companyName = "TestCompany"; + Integer pointValance = 200; + + MyPageProjection mockResult + = new MyPageProjection(memberName, companyName, pointValance); + + Mockito.when(memberRepository.findMemberCompanyPointData(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(mockResult)); + + // When + MemberResponse.MyPageDTO actual = memberService.getMyPageData(memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(memberName, actual.getMemberName()), + () -> Assertions.assertEquals(companyName, actual.getCompanyName()), + () -> Assertions.assertEquals(pointValance, actual.getPointValance()) + ); + } + + @DisplayName("실패 - 유효하지 않은 회원 정보") + @Test + void failure_Test_InvalidMember() { + // Given + Long memberId = 1L; + + // When + Mockito.when(memberRepository.findMemberCompanyPointData(ArgumentMatchers.anyLong())) + .thenReturn(Optional.empty()); + + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + memberService.getMyPageData(memberId)); + } + } + + @DisplayName("Success - 홈 화면에 필요한 정보 응답") + @Test + void getHomeInfoSuccess() { + // given + Long memberId = 1L; + Long buildingId = 1L; + String buildingName = "테라 타워"; + Boolean hasCafeteria = true; + + BuildingInfoProjection buildingInfoProjection = new BuildingInfoProjection(buildingId, buildingName, hasCafeteria); + + given(memberRepository.findBuildingInfoByMemberId(memberId)) + .willReturn(Optional.of(buildingInfoProjection) + ); + + // when + BuildingInfoDto actual = memberService.getBuildingInfo(memberId); + + // then + assertAll( + () -> Assertions.assertEquals(buildingId, actual.getBuildingId()), + () -> Assertions.assertEquals(buildingName, actual.getBuildingName()), + () -> Assertions.assertEquals(hasCafeteria, actual.getHasCafeteria()) + ); + + } + + @DisplayName("FAILED : 홈 화면에 필요한 정보 응답 - 유효 하지 않은 정보") + @Test + void getHomeInfoFailed() { + // given + Long memberId = 1L; + + // when + Mockito.when(memberRepository.findBuildingInfoByMemberId(anyLong())) + .thenReturn(Optional.empty()); + + // then + assertThrows(GlobalRuntimeException.class, () -> + memberService.getBuildingInfo(memberId)); + } + + @DisplayName("SUCCESS - 출입 카드 정보 응답 서비스 테스트") + @Test + void getAccessCardSuccess() { + // given + String buildingName = "테라 타워"; + String employeeNumber = "123456"; + String companyName = "OFFICE 01"; + String floor = "1층"; + String roomNumber = "101호" ; + String employeeName = "TestUser"; + + AccessCardProjection accessCardProjection = new AccessCardProjection(buildingName, employeeNumber, companyName, floor, roomNumber, employeeName); + List accessCardProjectionList = new ArrayList<>(); + accessCardProjectionList.add(accessCardProjection); + + given(memberRepository.findAccessCardData(anyLong())) + .willReturn(accessCardProjectionList); + + // when + AccessCardDto actual = memberService.getAccessCardData(anyLong()); + + // then + assertAll( + () -> Assertions.assertEquals(buildingName, actual.getBuildingName()), + () -> Assertions.assertEquals(employeeNumber, actual.getEmployeeNumber()), + () -> Assertions.assertEquals(companyName, actual.getCompanyName()), + () -> Assertions.assertEquals(floor, actual.getFloor()), + () -> Assertions.assertEquals(roomNumber, actual.getRoomNumber()), + () -> Assertions.assertEquals(employeeName, actual.getEmployeeName()) + ); + } + + @DisplayName("FAILED - 출입 카드 정보 응답 서비스 테스트 - 조회 실패") + @Test + void getAccessCardFail() { + // given + Long memberId = 1L; + + // when + Mockito.when(memberRepository.findAccessCardData(anyLong())) + .thenReturn(new ArrayList<>()); + + // then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + memberService.getAccessCardData(memberId)); + } + +} diff --git a/src/test/java/com/livable/server/menu/controller/MenuControllerTest.java b/src/test/java/com/livable/server/menu/controller/MenuControllerTest.java new file mode 100644 index 00000000..5e39b1b6 --- /dev/null +++ b/src/test/java/com/livable/server/menu/controller/MenuControllerTest.java @@ -0,0 +1,216 @@ +package com.livable.server.menu.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.menu.domain.MenuErrorCode; +import com.livable.server.menu.dto.MenuRequest.MenuChoiceLogDTO; +import com.livable.server.menu.dto.MenuResponse.MostSelectedMenuDTO; +import com.livable.server.menu.dto.MenuResponse.RouletteMenuDTO; +import com.livable.server.menu.service.MenuService; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import javax.validation.ConstraintViolationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@Import(TestConfig.class) +@WebMvcTest(MenuController.class) +class MenuControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + JwtTokenProvider tokenProvider; + + @MockBean + MenuService menuService; + + @DisplayName("SUCCESS - 룰렛에 사용되는 카테고리, 메뉴 응답 컨트롤러 테스트") + @Test + void getRouletteMenusSuccess() throws Exception { + //given + String token = tokenProvider.createActorToken( + ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + List mockResponse = new ArrayList<>(); + + given(menuService.getRouletteMenus()) + .willReturn(mockResponse); + + //when & then + mockMvc.perform( + get("/api/menus") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @DisplayName("FAIELD - 룰렛에 사용되는 카테고리, 메뉴 응답 컨트롤러 테스트") + @Test + void getRouletteMenusFailed() throws Exception { + //given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + List mockResponse = new ArrayList<>(); + + given(menuService.getRouletteMenus()) + .willThrow(new GlobalRuntimeException(MenuErrorCode.RETRIEVE_ROULETTE_MENU_FAILED)); + + //when & then + mockMvc.perform(get("/api/menus") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(MenuErrorCode.RETRIEVE_ROULETTE_MENU_FAILED.getMessage())); + } + + @DisplayName("SUCCESS - 가장 많이 선택한 메뉴 10위까지 응답 컨트롤러 테스트") + @Test + void getMostSelectedMenusSuccess() throws Exception { + //given + String token = tokenProvider.createActorToken( + ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + Pageable pageable = PageRequest.of(0, 1); + + List mockResponse = new ArrayList<>(); + + given(menuService.getMostSelectedMenu(1L, pageable)) + .willReturn(mockResponse); + + //when & then + mockMvc.perform( + get("/api/menus/choices?buildingId=1") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + + + @DisplayName("SUCCESS - 룰렛 선택 결과 저장 컨트롤러 테스트 : 정상") + @Test + void createMenuChoiceLogSuccess() throws Exception { + //given + Long memberId = 1L; + + String token = tokenProvider.createActorToken( + ActorType.MEMBER, memberId, new Date(new Date().getTime() + 10000000)); + + MenuChoiceLogDTO menuChoiceLogDTO = new MenuChoiceLogDTO(1L, LocalDate.now()); + + doAnswer(invocation -> { + return null; + }).when(menuService).createMenuChoiceLog(anyLong(), any(MenuChoiceLogDTO.class)); + + + //when & then + mockMvc.perform( + MockMvcRequestBuilders.post("/api/menus/choices") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(menuChoiceLogDTO))) + .andExpect(status().isCreated()); + } + + @DisplayName("FAILED - 룰렛 선택 결과 저장 컨트롤러 테스트 : 오늘이 아닌 날짜 요청") + @Test + void createMenuChoiceLogWithInvalidDate() throws Exception { + //given + Long memberId = 1L; + + String token = tokenProvider.createActorToken( + ActorType.MEMBER, memberId, new Date(new Date().getTime() + 10000000)); + + LocalDate pastDate = LocalDate.now().minusDays(1); + + MenuChoiceLogDTO invalidDto = new MenuChoiceLogDTO(1L, pastDate); + + doThrow(ConstraintViolationException.class) + .when(menuService) + .createMenuChoiceLog(anyLong(), any(MenuChoiceLogDTO.class)); + + //when & then + mockMvc.perform( + MockMvcRequestBuilders.post("/api/menus/choices") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDto))) + .andExpect(status().isBadRequest()); + } + + @DisplayName("FAILED - 룰렛 선택 결과 저장 컨트롤러 테스트 : 잘못된 요청 - 존재 하지 않는 유저") + @Test + void createMenuChoiceLogInvalidMember() throws Exception { + //given + + String token = tokenProvider.createActorToken( + ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + MenuChoiceLogDTO invalidDto = new MenuChoiceLogDTO(1L, LocalDate.now()); + + doThrow(new GlobalRuntimeException(MemberErrorCode.MEMBER_NOT_EXIST)) + .when(menuService) + .createMenuChoiceLog(anyLong(), any(MenuChoiceLogDTO.class)); + + //when & then + mockMvc.perform( + MockMvcRequestBuilders.post("/api/menus/choices") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDto))) + .andExpect(status().isBadRequest()); + } + + @DisplayName("FAILED - 룰렛 선택 결과 저장 컨트롤러 테스트 : 잘못된 요청 - 존재 하지 않는 메뉴") + @Test + void createMenuChoiceLogInvalidMenu() throws Exception { + //given + + String token = tokenProvider.createActorToken( + ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + MenuChoiceLogDTO invalidDto = new MenuChoiceLogDTO(1L, LocalDate.now()); + + doThrow(new GlobalRuntimeException(MenuErrorCode.MENU_NOT_EXIST)) + .when(menuService) + .createMenuChoiceLog(anyLong(), any(MenuChoiceLogDTO.class)); + + //when & then + mockMvc.perform( + MockMvcRequestBuilders.post("/api/menus/choices") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDto))) + .andExpect(status().isBadRequest()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/menu/service/MenuChoiceResultServiceTest.java b/src/test/java/com/livable/server/menu/service/MenuChoiceResultServiceTest.java new file mode 100644 index 00000000..4d405b96 --- /dev/null +++ b/src/test/java/com/livable/server/menu/service/MenuChoiceResultServiceTest.java @@ -0,0 +1,49 @@ +package com.livable.server.menu.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.livable.server.menu.dto.MenuChoiceProjection; +import com.livable.server.menu.repository.MenuChoiceLogRepository; +import com.livable.server.menu.repository.MenuChoiceResultRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MenuChoiceResultServiceTest { + + @InjectMocks + MenuChoiceResultService menuChoiceResultService; + + @Mock + MenuChoiceLogRepository menuChoiceLogRepository; + + @Mock + MenuChoiceResultRepository menuChoiceResultRepository; + + @DisplayName("SUCCESS - 가장 많이 선택한 메뉴 통계 집계 성공") + @Test + void MenuChoiceResultSuccess() { + // given + List menuChoiceLogs = new ArrayList<>(); + given(menuChoiceLogRepository.findMenuChoiceLog(any(LocalDate.class))).willReturn(menuChoiceLogs); + + // when + menuChoiceResultService.createDailyMenuChoiceResult(LocalDate.now()); + + // then + verify(menuChoiceResultRepository, times(1)).saveAll(anyList()); + } + +} diff --git a/src/test/java/com/livable/server/menu/service/MenuServiceTest.java b/src/test/java/com/livable/server/menu/service/MenuServiceTest.java new file mode 100644 index 00000000..c599cdff --- /dev/null +++ b/src/test/java/com/livable/server/menu/service/MenuServiceTest.java @@ -0,0 +1,252 @@ +package com.livable.server.menu.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Building; +import com.livable.server.entity.Company; +import com.livable.server.entity.Member; +import com.livable.server.entity.Menu; +import com.livable.server.entity.MenuChoiceLog; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.menu.domain.MenuChoiceResultDateRange; +import com.livable.server.menu.domain.MenuErrorCode; +import com.livable.server.menu.dto.MenuRequest.MenuChoiceLogDTO; +import com.livable.server.menu.dto.MenuResponse.MostSelectedMenuDTO; +import com.livable.server.menu.dto.MenuResponse.RouletteMenuDTO; +import com.livable.server.menu.dto.MostSelectedMenuProjection; +import com.livable.server.menu.dto.RouletteMenu; +import com.livable.server.menu.dto.RouletteMenuProjection; +import com.livable.server.menu.repository.MenuChoiceLogRepository; +import com.livable.server.menu.repository.MenuRepository; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class MenuServiceTest { + + @InjectMocks + MenuService menuService; + + @Mock + MenuRepository menuRepository; + @Mock + MenuChoiceLogRepository menuChoiceLogRepository; + @Mock + MemberRepository memberRepository; + + @DisplayName("SUCCESS - 룰렛에 사용되는 카테고리, 메뉴 응답 서비스 테스트") + @Test + void getRouletteMenusSuccess() { + + //given + String categoryName = "카테고리1"; + String menuName = "메뉴1"; + + RouletteMenuProjection rouletteMenuProjection = new RouletteMenuProjection(1L, menuName, categoryName); + + List rouletteMenuProjections = new ArrayList<>(); + rouletteMenuProjections.add(rouletteMenuProjection); + + RouletteMenu rouletteMenu = RouletteMenu.from(rouletteMenuProjection); + + List result = new ArrayList<>(); + result.add(rouletteMenu); + + RouletteMenuDTO expected = new RouletteMenuDTO(categoryName, result); + + given(menuRepository.findRouletteMenus()) + .willReturn(rouletteMenuProjections); + + //when + List actual = + menuService.getRouletteMenus(); + + //then + then(menuRepository).should(times(1)).findRouletteMenus(); + assertAll( + () -> assertEquals(expected.getCategoryName(), actual.get(0).getCategoryName()), + () -> assertEquals(expected.getMenus().size(), actual.get(0).getMenus().size()) + ); + } + + @DisplayName("FAIELD - 룰렛에 사용되는 카테고리, 메뉴 응답 서비스 테스트") + @Test + void getRouletteMenusFailed() { + + //given + given(menuRepository.findRouletteMenus()) + .willReturn(new ArrayList<>()); + + //when + assertThrows(GlobalRuntimeException.class, () -> + menuService.getRouletteMenus()); + } + + @DisplayName("SUCCESS - 가장 많이 선택한 메뉴 10위까지 응답 서비스 테스트") + @Test + void getMostSelectedMenusSuccess() { + + //given + + Long buildingId = 1L; + Integer count = 4; + LocalDate date = LocalDate.now(); + Long menuId = 1L; + String menuName = "메뉴1"; + String menuUrl = "/dummyUrl"; + int pageLimit = 1; + Pageable pageable = PageRequest.of(5, pageLimit); + MenuChoiceResultDateRange referenceDate = MenuChoiceResultDateRange.getDateRange(date); + + + MostSelectedMenuProjection mostSelectedMenuProjection = new MostSelectedMenuProjection(count, + date, menuId, menuName, menuUrl); + + List mostSelectedMenuProjections = new ArrayList<>(); + mostSelectedMenuProjections.add(mostSelectedMenuProjection); + + List result = new ArrayList<>(); + + for (int i = 0; i < mostSelectedMenuProjections.size() ; i++) { + MostSelectedMenuDTO mostSelectedMenuDTO = MostSelectedMenuDTO.from(mostSelectedMenuProjection, i); + result.add(mostSelectedMenuDTO); + } + + MostSelectedMenuDTO expected = new MostSelectedMenuDTO(date, count, pageLimit, menuId, menuName, menuUrl); + + given(menuRepository.findMostSelectedMenuOrderByCount(buildingId, referenceDate.getEndDate(), pageable)) + .willReturn(mostSelectedMenuProjections); + + //when + List actual = + menuService.getMostSelectedMenu(buildingId, pageable); + + //then + assertAll( + () -> assertEquals(expected.getCount(), actual.get(0).getCount()), + () -> assertEquals(expected.getMenuId(), actual.get(0).getMenuId()), + () -> assertEquals(expected.getRank(), actual.get(0).getRank()), + () -> assertEquals(expected.getMenuName(), actual.get(0).getMenuName()), + () -> assertEquals(expected.getMenuImage(), actual.get(0).getMenuImage()), + () -> assertEquals(expected.getDate(), actual.get(0).getDate()) + ); + + } + + @DisplayName("SUCCESS - 룰렛 선택 결과 저장 서비스 테스트 : 당일 첫 룰렛 선택 결과 반영") + @Test + void createMenuChoiceLog() { + //given + Long memberId = 1L; + MenuChoiceLogDTO menuChoiceLogDTO = new MenuChoiceLogDTO(1L, LocalDate.now()); + + Member mockMember = mock(Member.class); + Company mockCompany = mock(Company.class); + Building mockBuilding = mock(Building.class); + Menu mockMenu = mock(Menu.class); + MenuChoiceLog mockMenuChoiceLog = mock(MenuChoiceLog.class); + + when(memberRepository.findById(anyLong())).thenReturn(Optional.of(mockMember)); + when(menuRepository.findById(anyLong())).thenReturn(Optional.of(mockMenu)); + when(mockMember.getCompany()).thenReturn(mockCompany); + when(mockCompany.getBuilding()).thenReturn(mockBuilding); + when(menuChoiceLogRepository.save(any(MenuChoiceLog.class))).thenReturn(mockMenuChoiceLog); + + //when + menuService.createMenuChoiceLog(memberId, menuChoiceLogDTO); + + //then + verify(menuChoiceLogRepository).save(any(MenuChoiceLog.class)); + } + + @DisplayName("SUCCESS - 룰렛 선택 결과 저장 서비스 테스트 : 룰렛 선택 결과 업데이트 반영") + @Test + void updateMenuChoiceLog() { + //given + Long memberId = 1L; + MenuChoiceLogDTO menuChoiceLogDTO = new MenuChoiceLogDTO(1L, LocalDate.now()); + + Member mockMember = mock(Member.class); + Company mockCompany = mock(Company.class); + Building mockBuilding = mock(Building.class); + Menu mockMenu = mock(Menu.class); + MenuChoiceLog mockExistingMenuChoiceLog = mock(MenuChoiceLog.class); + + + when(memberRepository.findById(anyLong())).thenReturn(Optional.of(mockMember)); + when(menuRepository.findById(anyLong())).thenReturn(Optional.of(mockMenu)); + when(mockMember.getCompany()).thenReturn(mockCompany); + when(mockCompany.getBuilding()).thenReturn(mockBuilding); + when(menuChoiceLogRepository.findByMemberIdAndDate(anyLong(), any(LocalDate.class))).thenReturn(Optional.of(mockExistingMenuChoiceLog)); + + //when + menuService.createMenuChoiceLog(memberId, menuChoiceLogDTO); + + //then + verify(menuChoiceLogRepository).findByMemberIdAndDate(anyLong(), any(LocalDate.class)); + verify(menuChoiceLogRepository).save(any(MenuChoiceLog.class)); + } + + @DisplayName("FAILED - 룰렛 선택 결과 저장 서비스 테스트 : 잘못된 요청 - 존재 하지 않는 유저") + @Test + void createMenuChoiceLogInvalidMember() { + //given + Long memberId = 1L; + MenuChoiceLogDTO menuChoiceLogDTO = new MenuChoiceLogDTO(1L, LocalDate.now()); + + //when & then + GlobalRuntimeException globalRuntimeException = assertThrows(GlobalRuntimeException.class, () -> + menuService.createMenuChoiceLog(memberId, menuChoiceLogDTO)); + assertEquals(MemberErrorCode.MEMBER_NOT_EXIST, globalRuntimeException.getErrorCode()); + } + + @DisplayName("FAILED - 룰렛 선택 결과 저장 서비스 테스트 : 잘못된 요청 - 존재 하지 않는 메뉴") + @Test + void createMenuChoiceLogInvalidMenu() { + //given + Long memberId = 1L; + MenuChoiceLogDTO menuChoiceLogDTO = new MenuChoiceLogDTO(1L, LocalDate.now()); + + Member mockMember = mock(Member.class); + Company mockCompany = mock(Company.class); + Building mockBuilding = mock(Building.class); + + when(mockMember.getCompany()).thenReturn(mockCompany); + when(mockCompany.getBuilding()).thenReturn(mockBuilding); + when(memberRepository.findById(memberId)).thenReturn(Optional.of(mockMember)); + + //when & then + GlobalRuntimeException globalRuntimeException = assertThrows(GlobalRuntimeException.class, () -> + menuService.createMenuChoiceLog(memberId, menuChoiceLogDTO)); + assertEquals(MenuErrorCode.MENU_NOT_EXIST, globalRuntimeException.getErrorCode()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/point/controller/PointControllerTest.java b/src/test/java/com/livable/server/point/controller/PointControllerTest.java new file mode 100644 index 00000000..d482ed46 --- /dev/null +++ b/src/test/java/com/livable/server/point/controller/PointControllerTest.java @@ -0,0 +1,86 @@ +package com.livable.server.point.controller; + +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.point.dto.PointResponse; +import com.livable.server.point.service.PointService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.time.LocalDateTime; +import java.util.Date; + +@Import(TestConfig.class) +@WebMvcTest(PointController.class) +class PointControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider tokenProvider; + + @MockBean + private PointService pointService; + + @Nested + @DisplayName("나의 리뷰 카운트 컨트롤러 단위 테스트") + class MyRestaurantReview { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/points/logs/members"; + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + PointResponse.ReviewCountDTO mockResponse + = new PointResponse.ReviewCountDTO(5L); + + Mockito.when(pointService.getMyReviewCount( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(mockResponse); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .header("Authorization", "Bearer " + token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()); + } + } + + @Nested + @DisplayName("목표 달성 포인트 지급 컨트롤러 단위 테스트") + class GetAchievementPoint { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/points/logs/members"; + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.post(uri) + .header("Authorization", "Bearer " + token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isCreated()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/point/domain/DateFactoryTest.java b/src/test/java/com/livable/server/point/domain/DateFactoryTest.java new file mode 100644 index 00000000..0acfe2ea --- /dev/null +++ b/src/test/java/com/livable/server/point/domain/DateFactoryTest.java @@ -0,0 +1,36 @@ +package com.livable.server.point.domain; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +class DateFactoryTest { + + private DateFactory dateFactory; + + @BeforeEach + void setUp() { + dateFactory = new DateFactory(); + } + + @DisplayName("한달의 시작과 끝을 반환하는 메서드 테스트 - 성공") + @Test + void getMonthRange_Success_Test() { + // Given + LocalDateTime startDate = LocalDateTime.of(2023, 1, 1, 0, 0); + LocalDateTime endDate = LocalDateTime.of(2023, 2, 1, 0, 0); + LocalDateTime localDateTime = LocalDateTime.of(2023, 1, 1, 1, 1); + + // When + DateRange actual = dateFactory.getMonthRangeOf(localDateTime); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(startDate, actual.getStartDate()), + () -> Assertions.assertEquals(endDate, actual.getEndDate()) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/point/domain/MenuChoiceResultDateRangeTest.java b/src/test/java/com/livable/server/point/domain/MenuChoiceResultDateRangeTest.java new file mode 100644 index 00000000..4fee4b0d --- /dev/null +++ b/src/test/java/com/livable/server/point/domain/MenuChoiceResultDateRangeTest.java @@ -0,0 +1,30 @@ +package com.livable.server.point.domain; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +class MenuChoiceResultDateRangeTest { + + @DisplayName("생성자 테스트 - 성공") + @Test + void success_Test() { + // Given + LocalDateTime startDate = LocalDateTime.of(2023, 1, 23, 4, 12); + LocalDateTime endDate = LocalDateTime.of(2023, 2, 23, 4, 12); + + LocalDateTime expectedStartDate = LocalDateTime.of(2023, 1, 23, 0, 0); + LocalDateTime expectedEndDate = LocalDateTime.of(2023, 2, 23, 0, 0); + + // When + DateRange actual = new DateRange(startDate, endDate); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(expectedStartDate, actual.getStartDate()), + () -> Assertions.assertEquals(expectedEndDate, actual.getEndDate()) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/point/service/PointServiceTest.java b/src/test/java/com/livable/server/point/service/PointServiceTest.java new file mode 100644 index 00000000..194ee876 --- /dev/null +++ b/src/test/java/com/livable/server/point/service/PointServiceTest.java @@ -0,0 +1,409 @@ +package com.livable.server.point.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Point; +import com.livable.server.entity.PointCode; +import com.livable.server.entity.PointLog; +import com.livable.server.entity.Review; +import com.livable.server.point.domain.DateFactory; +import com.livable.server.point.domain.DateRange; +import com.livable.server.point.domain.PointErrorCode; +import com.livable.server.point.dto.PointResponse; +import com.livable.server.point.repository.PointLogRepository; +import com.livable.server.point.repository.PointRepository; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +class PointServiceTest { + + @Mock + private PointRepository pointRepository; + + @Mock + private PointLogRepository pointLogRepository; + + @Mock + private DateFactory dateFactory; + + @InjectMocks + private PointService pointService; + + @Nested + @DisplayName("나의 리뷰 카운트 서비스 단위 테스트") + class MyRestaurantReview { + + @DisplayName("성공") + @Test + void success_Test() { + // Given + Long memberId = 1L; + LocalDateTime currentDate = LocalDateTime.now(); + PointResponse.ReviewCountDTO countDTO = new PointResponse.ReviewCountDTO(5L); + Point point = Point.builder().id(1L).build(); + + LocalDateTime startDate = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime endDte = LocalDateTime.of(2023, 2, 1, 0, 0, 0); + DateRange dateRange = new DateRange(startDate, endDte); + + Mockito.when(dateFactory.getMonthRangeOf(ArgumentMatchers.any(LocalDateTime.class))) + .thenReturn(dateRange); + + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointRepository.findPointCountById( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.anyList() + )).thenReturn(countDTO); + + // When + PointResponse.ReviewCountDTO actual = pointService.getMyReviewCount(memberId, currentDate); + + // Then + Assertions.assertEquals(5L, actual.getCount()); + } + + @DisplayName("실패 - 유효하지 않은 포인트 정보") + @Test + void failure_Test_existPointData() { + // Given + Long memberId = 1L; + LocalDateTime currentDate = LocalDateTime.now(); + + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.empty()); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + pointService.getMyReviewCount(memberId, currentDate)); + } + } + + @Nested + @MockitoSettings(strictness = Strictness.LENIENT) + @DisplayName("목표 달성 포인트 지급 서비스 단위 테스트") + class GetAchievementPoint { + + @BeforeEach + void setUp() { + LocalDateTime startDate = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime endDte = LocalDateTime.of(2023, 2, 1, 0, 0, 0); + DateRange dateRange = new DateRange(startDate, endDte); + + Mockito.when(dateFactory.getMonthRangeOf(ArgumentMatchers.any(LocalDateTime.class))) + .thenReturn(dateRange); + + LocalDate pureDate = LocalDate.of(2023, 1, 1); + Mockito.when(dateFactory.getPureDate(ArgumentMatchers.any(LocalDateTime.class))) + .thenReturn(pureDate); + } + + @DisplayName("실패 - 회원 아이디로부터 포인트 테이블이 조회되지 않는 경우, 예외를 발생한다.") + @Test + void failure_Test_GivenInvalidMemberId_ThenThrowsErrorWithMessage() { + // Given + Long memberId = 1L; + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + + // When + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.empty()); + + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> pointService.getAchievementPoint(memberId, requestTime)); + try { + pointService.getAchievementPoint(memberId, requestTime); + } catch (GlobalRuntimeException exception) { + Assertions.assertEquals(PointErrorCode.POINT_NOT_EXIST.getMessage(), exception.getMessage()); + } + } + + @DisplayName("실패 - 포인트 데이터로부터 포인트 로그 테이블의 데이터가 존재하지 않는 경우, 예외를 발생한다.") + @Test + void failure_Test_GivenHaveNothingLogPoint_ThenThrowsErrorWithMessage() { + // Given + Long memberId = 1L; + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + + Point point = Point.builder().id(1L).balance(10).build(); + List mockList = List.of(); + + // When + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class)) + ).thenReturn(mockList); + + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> pointService.getAchievementPoint(memberId, requestTime)); + try { + pointService.getAchievementPoint(memberId, requestTime); + } catch (GlobalRuntimeException exception) { + Assertions.assertEquals(PointErrorCode.POINT_NOT_EXIST_FOR_CURRENT_MONTH.getMessage(), exception.getMessage()); + } + } + + @DisplayName("성공 - 7개의 리뷰 포인트 로그가 주어지는 경우, 목표 포인트를 지급한다.") + @Test + void success_Test_GivenSevenReviewPointLog_ThenPaidAchievementPoint() { + // Given + Long memberId = 1L; + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime paidTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + + Point point = Point.builder().id(1L).balance(10).build(); + Review review = Review.builder().id(1L).build(); + List MockList = List.of( + PointLog.builder().id(1L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(2L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(3L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(4L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(5L).point(point).review(review).code(PointCode.PA02).paidAt(paidTime).build(), + PointLog.builder().id(6L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(7L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build() + ); + + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(MockList); + + // When + // Then + pointService.getAchievementPoint(memberId, requestTime); + Assertions.assertEquals(110, point.getBalance()); + } + + @DisplayName("성공 - 14개의 리뷰 포인트 로그가 주어지는 경우, 목표 포인트를 지급한다.") + @Test + void success_Test_GivenFourTeenReviewPointLog_ThenPaidAchievementPoint() { + // Given + Long memberId = 1L; + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime paidTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + + Point point = Point.builder().id(1L).balance(10).build(); + Review review = Review.builder().id(1L).build(); + List MockList = List.of( + PointLog.builder().id(1L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(2L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(3L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(4L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(5L).point(point).review(review).code(PointCode.PA02).paidAt(paidTime).build(), + PointLog.builder().id(6L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(7L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(8L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(9L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(10L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(11L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(12L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(13L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(14L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build() + ); + + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(MockList); + + // When + // Then + pointService.getAchievementPoint(memberId, requestTime); + Assertions.assertEquals(110, point.getBalance()); + } + + @DisplayName("실패 - 3개의 리뷰 포인트 로그가 주어지는 경우, 목표를 달성하지 않았다는 메시지 예외를 발생한다.") + @Test + void failure_Test_GivenThreeReviewPointLog_ThenThrowsErrorWithMessage() { + // Given + Long memberId = 1L; + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime paidTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + + Point point = Point.builder().id(1L).balance(10).build(); + Review review = Review.builder().id(1L).build(); + List MockList = List.of( + PointLog.builder().id(1L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(2L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(3L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build() + ); + + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(MockList); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, + () -> pointService.getAchievementPoint(memberId, requestTime)); + + try { + pointService.getAchievementPoint(memberId, requestTime); + } catch (GlobalRuntimeException exception) { + Assertions.assertEquals( + PointErrorCode.ACHIEVEMENT_POINT_NOT_MATCHED.getMessage(), exception.getMessage()); + } + } + + @DisplayName("실패 - 11개의 리뷰 포인트 로그가 주어지는 경우, 목표를 달성하지 않았다는 메시지 예외를 발생한다.") + @Test + void failure_Test_GivenElevenReviewPointLog_ThenThrowsErrorWithMessage() { + // Given + Long memberId = 1L; + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime paidTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + + Point point = Point.builder().id(1L).balance(10).build(); + Review review = Review.builder().id(1L).build(); + List MockList = List.of( + PointLog.builder().id(1L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(2L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(3L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(4L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(5L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(6L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(7L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(8L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(9L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(10L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(11L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build() + ); + + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(MockList); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, + () -> pointService.getAchievementPoint(memberId, requestTime)); + + try { + pointService.getAchievementPoint(memberId, requestTime); + } catch (GlobalRuntimeException exception) { + Assertions.assertEquals( + PointErrorCode.ACHIEVEMENT_POINT_NOT_MATCHED.getMessage(), exception.getMessage()); + } + } + + @DisplayName("실패 - 목표 달성 포인트를 지급받은 이후 다시 포인트 지급을 요청하는 경우, 예외를 발생한다.") + @Test + void failure_Test_WhenDuplicateRequest_ThenThrowError() { + // Given + Long memberId = 1L; + Point point = Point.builder().id(1L).balance(10).build(); + Review review = Review.builder().id(1L).build(); + + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime paidTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + List MockList = List.of( + PointLog.builder().id(1L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(2L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(3L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(4L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(5L).point(point).review(review).code(PointCode.PA02).paidAt(paidTime).build(), + PointLog.builder().id(6L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(7L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(8L).point(point).review(review).code(PointCode.PA03).paidAt(paidTime).build() + ); + + // When + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(MockList); + + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> pointService.getAchievementPoint(memberId, requestTime)); + try { + pointService.getAchievementPoint(memberId, requestTime); + } catch (GlobalRuntimeException exception) { + Assertions.assertEquals(PointErrorCode.ACHIEVEMENT_POINT_PAID_ALREADY.getMessage(), exception.getMessage()); + } + } + + @DisplayName("실패 - 목표는 달성 했지만 목표 달성일 이후 포인트 지급을 요청한 경우, 예외를 발생한다.") + @Test + void failure_Test_GivenRequestNotAchievingDate_ThenThrowError() { + // Given + Long memberId = 1L; + Point point = Point.builder().id(1L).balance(10).build(); + Review review = Review.builder().id(1L).build(); + + LocalDateTime requestTime = LocalDateTime.of(2023, 1, 4, 0, 0, 0); + LocalDateTime paidTime = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + List MockList = List.of( + PointLog.builder().id(1L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(2L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build(), + PointLog.builder().id(3L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(4L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(5L).point(point).review(review).code(PointCode.PA02).paidAt(paidTime).build(), + PointLog.builder().id(6L).point(point).review(review).code(PointCode.PA00).paidAt(paidTime).build(), + PointLog.builder().id(7L).point(point).review(review).code(PointCode.PA01).paidAt(paidTime).build() + ); + + // When + Mockito.when(pointRepository.findByMemberId(ArgumentMatchers.anyLong())) + .thenReturn(Optional.of(point)); + + Mockito.when(pointLogRepository.findDateRangeOfPointLogByPointId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(LocalDateTime.class), + ArgumentMatchers.any(LocalDateTime.class) + )).thenReturn(MockList); + + Mockito.when(dateFactory.getPureDate(requestTime)) + .thenReturn(LocalDate.of(2023, 1, 4)); + + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> pointService.getAchievementPoint(memberId, requestTime)); + try { + pointService.getAchievementPoint(memberId, requestTime); + } catch (GlobalRuntimeException exception) { + Assertions.assertEquals(PointErrorCode.ACHIEVEMENT_POINT_PAID_FAILED.getMessage(), exception.getMessage()); + } + } + } +} diff --git a/src/test/java/com/livable/server/reservation/controller/ReservationControllerTest.java b/src/test/java/com/livable/server/reservation/controller/ReservationControllerTest.java new file mode 100644 index 00000000..46af7cf8 --- /dev/null +++ b/src/test/java/com/livable/server/reservation/controller/ReservationControllerTest.java @@ -0,0 +1,80 @@ +package com.livable.server.reservation.controller; + +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.reservation.domain.ReservationRequest; +import com.livable.server.reservation.dto.ReservationResponse; +import com.livable.server.reservation.service.ReservationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Import(TestConfig.class) +@WebMvcTest(ReservationController.class) +class ReservationControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + JwtTokenProvider tokenProvider; + + @MockBean + ReservationService reservationService; + + @DisplayName( + "[GET][/api/reservation/places/{commonPlaceId}?date={yyyy-MM-dd}] - 특정 회의실의 사용 가능 시간 응답(예약해둔 시간)" + ) + @Test + void findAvailableTimesSuccessTest() throws Exception { + // given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + List result = IntStream.range(1, 10) + .mapToObj(idx -> ReservationResponse.AvailableReservationTimePerDateDto.builder() + .date(LocalDate.now()) + .availableTimes(new ArrayList<>(List.of(LocalTime.now()))) + .build() + ) + .collect(Collectors.toList()); + + given(reservationService.findAvailableReservationTimes(anyLong(), anyLong(), any(ReservationRequest.DateQuery.class))) + .willReturn(result); + + // when + ResultActions resultActions = mockMvc.perform( + get("/api/reservation/places/1") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("startDate", "2023-09-22") + .queryParam("endDate", "2023-09-22") + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].date").isString()) + .andExpect(jsonPath("$.data[0].availableTimes").isArray()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/reservation/repository/ReservationRepositoryTest.java b/src/test/java/com/livable/server/reservation/repository/ReservationRepositoryTest.java new file mode 100644 index 00000000..56a51248 --- /dev/null +++ b/src/test/java/com/livable/server/reservation/repository/ReservationRepositoryTest.java @@ -0,0 +1,159 @@ +package com.livable.server.reservation.repository; + +import com.livable.server.core.config.QueryDslConfig; +import com.livable.server.entity.*; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.reservation.dto.AvailableReservationTimeProjection; +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.context.annotation.Import; + +import javax.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +@Import(QueryDslConfig.class) +class ReservationRepositoryTest { + + public static final LocalDate START_DATE = LocalDate.now(); + public static final LocalTime START_TIME = LocalTime.of(1, 10); + public static final LocalTime END_TIME = LocalTime.of(1, 20); + public static final LocalDate END_DATE = LocalDate.now(); + + @Autowired + ReservationRepository reservationRepository; + + @Autowired + EntityManager entityManager; + + @Autowired + MemberRepository memberRepository; + + @BeforeEach + void dateInit() { + Building building = Building.builder() + .name("63빌딩") + .scale("지하 3층, 지상 63층") + .representativeImageUrl("./thumbnailImage.jpg") + .endTime(LocalTime.of(10, 30)) + .startTime(LocalTime.of(18, 30)) + .parkingCostInformation("10분당 1000원") + .longitude("10.10.10.10") + .latitude("123.123.123") + .hasCafeteria(false) + .address("서울시 강남구 서초대로 61길 7, 392") + .subwayStation("석촌역") + .build(); + + entityManager.persist(building); + + Company company = Company.builder() + .name("패스트캠퍼스") + .building(building) + .build(); + + entityManager.persist(company); + + Member member = Member.builder() + .company(company) + .contact("01012345678") + .name("김훈섭") + .email("test@naver.com") + .employeeNumber("9q0mavfdmmpoaskp") + .profileImageUrl("./profileImageUrl") + .password("1234") + .role(Role.USER) + .build(); + + entityManager.persist(member); + + Invitation invitation = Invitation.builder() + .member(member) + .endDate(END_DATE) + .startDate(START_DATE) + .startTime(START_TIME) + .endTime(END_TIME) + .description("알아서 와") + .purpose("INTERVIEW") + .officeName("패스트캠퍼스 사무실") + .build(); + + entityManager.persist(invitation); + + List commonPlaceList = IntStream.range(0, 2) + .mapToObj( + idx -> CommonPlace.builder() + .name("commonPlace" + idx) + .floor("floor" + idx) + .roomNumber("roomNumber" + idx) + .building(building) + .build() + ) + .collect(Collectors.toList()); + commonPlaceList.forEach(entityManager::persist); + + List reservationList = IntStream.range(1, 10) + .mapToObj( + idx -> Reservation.builder() + .date(LocalDate.now()) + .time(LocalTime.of(10, 0, 0).plusMinutes(idx * 30L)) + .commonPlace(commonPlaceList.get(idx % 2)) + .company(company) + .build() + ) + .collect(Collectors.toList()); + + reservationList.forEach(entityManager::persist); + + List invitationReservationMapList = IntStream.range(0, 3) + .mapToObj(idx -> { + return InvitationReservationMap.builder() + .reservation(reservationList.get(idx)) + .invitation(invitation) + .build(); + }) + .collect(Collectors.toList()); + + invitationReservationMapList.forEach(entityManager::persist); + } + + @DisplayName("ReservationRepository.findNotUsedReservationTime 쿼리 성공 테스트") + @Test + void test() { + + List expectedResult = IntStream.range(4, 10) + .filter(idx -> idx % 2 == 0) + .mapToObj(idx -> + new AvailableReservationTimeProjection( + LocalDate.now(), LocalTime.of(10, 0, 0).plusMinutes(30L * idx) + ) + ) + .collect(Collectors.toList()); + + + Member member = entityManager.createQuery("select m from Member m", Member.class) + .getResultList() + .get(0); + + CommonPlace commonPlace = entityManager.createQuery("select cp from CommonPlace cp", CommonPlace.class) + .getResultList() + .get(0); + List notUsedReservationTime = + reservationRepository.findNotUsedReservationTime( + member.getCompany().getId(), commonPlace.getId(), LocalDate.now() + ); + + assertThat(expectedResult).usingRecursiveComparison().isEqualTo(notUsedReservationTime); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/reservation/service/ReservationServiceTest.java b/src/test/java/com/livable/server/reservation/service/ReservationServiceTest.java new file mode 100644 index 00000000..df084c58 --- /dev/null +++ b/src/test/java/com/livable/server/reservation/service/ReservationServiceTest.java @@ -0,0 +1,116 @@ +package com.livable.server.reservation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Company; +import com.livable.server.entity.Member; +import com.livable.server.invitation.repository.InvitationReservationMapRepository; +import com.livable.server.member.domain.MemberErrorCode; +import com.livable.server.member.repository.MemberRepository; +import com.livable.server.reservation.domain.ReservationRequest; +import com.livable.server.reservation.dto.AvailableReservationTimeProjection; +import com.livable.server.reservation.dto.AvailableReservationTimeProjections; +import com.livable.server.reservation.dto.ReservationResponse; +import com.livable.server.reservation.repository.ReservationRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class ReservationServiceTest { + + @InjectMocks + ReservationService reservationService; + + @Mock + ReservationRepository reservationRepository; + + @Mock + MemberRepository memberRepository; + + @Mock + InvitationReservationMapRepository invitationReservationMapRepository; + + @DisplayName("ReservationService.findAvailableReservationTimes 성공 테스트") + @Test + void findAvailableReservationTimesSuccessTest() { + + // given + Company company = Company.builder() + .id(1L) + .build(); + + Member member = Member.builder() + .id(1L) + .company(company) + .build(); + + List queryResult = IntStream.range(1, 5) + .mapToObj(idx -> new AvailableReservationTimeProjection( + LocalDate.now(), LocalTime.of(10, 0, 0).plusMinutes(idx * 30) + ) + ) + .collect(Collectors.toList()); + + AvailableReservationTimeProjections projections = new AvailableReservationTimeProjections(queryResult); + + ReservationRequest.DateQuery dateQuery = new ReservationRequest.DateQuery(LocalDate.now(), LocalDate.now().plusDays(1)); + + given(invitationReservationMapRepository.findAllReservationId()).willReturn(List.of(1L, 2L, 3L)); + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(reservationRepository.findNotUsedReservationTimeByUsedReservationIds( + anyLong(), anyLong(), any(LocalDate.class), any(LocalDate.class), any(List.class) + ) + ) + .willReturn(queryResult); + + // when + List result = + reservationService.findAvailableReservationTimes(1L, 1L, dateQuery); + + // then + then(invitationReservationMapRepository).should(times(1)).findAllReservationId(); + then(memberRepository).should(times(1)).findById(anyLong()); + then(reservationRepository).should(times(1)) + .findNotUsedReservationTimeByUsedReservationIds(anyLong(), anyLong(), any(LocalDate.class), any(LocalDate.class), any(List.class)); + + assertThat(result).usingRecursiveComparison().isEqualTo(projections.toDto()); + } + + @DisplayName("ReservationService.findAvailableReservationTimes 실패 테스트") + @Test + void findAvailableReservationTimesFailTest() { + // given + + given(memberRepository.findById(anyLong())).willReturn(Optional.empty()); + ReservationRequest.DateQuery dateQuery = + new ReservationRequest.DateQuery(LocalDate.now(), LocalDate.now().plusDays(1)); + + // when + GlobalRuntimeException globalRuntimeException = assertThrows(GlobalRuntimeException.class, () -> + reservationService.findAvailableReservationTimes(1L, 1L, dateQuery) + ); + + // then + then(memberRepository).should(times(1)).findById(anyLong()); + + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_EXIST); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/restaurant/controller/RestaurantControllerTest.java b/src/test/java/com/livable/server/restaurant/controller/RestaurantControllerTest.java new file mode 100644 index 00000000..5f11181b --- /dev/null +++ b/src/test/java/com/livable/server/restaurant/controller/RestaurantControllerTest.java @@ -0,0 +1,121 @@ +package com.livable.server.restaurant.controller; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.entity.RestaurantCategory; +import com.livable.server.restaurant.domain.RestaurantErrorCode; +import com.livable.server.restaurant.dto.RestaurantResponse; +import com.livable.server.restaurant.dto.RestaurantResponse.RestaurantsDto; +import com.livable.server.restaurant.mock.MockNearRestaurantDto; +import com.livable.server.restaurant.mock.MockRestaurantDto; +import com.livable.server.restaurant.service.RestaurantService; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@Import(TestConfig.class) +@WebMvcTest(RestaurantController.class) +class RestaurantControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + JwtTokenProvider tokenProvider; + + @MockBean + RestaurantService restaurantService; + + @DisplayName("[GET][/api/restaurant?type={category}] - 빌딩 내, 근처 식당 정상 응답") + @CsvSource(value = {"CAFE", "RESTAURANT", "restaurant", "Restaurant", "Cafe", "cafe"}) + @ParameterizedTest(name = "[{index}] category={0}") + void findRestaurantByCategorySuccessTest(String category) throws Exception { + // given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + + List result = + IntStream.range(1, 10) + .mapToObj(idx -> new MockNearRestaurantDto()) + .collect(Collectors.toList()); + + given(restaurantService.findNearRestaurantByVisitorIdAndRestaurantCategory( + 1L, RestaurantCategory.of(category) + )).willReturn(result); + + + // when + ResultActions resultActions = mockMvc.perform( + get("/api/restaurant") + .header("Authorization", "Bearer " + token) + .queryParam("type", "restaurant") + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + } + + @DisplayName("SUCCESS - 메뉴별 식당 목록 응답 컨트롤러 테스트") + @Test + void findRestaurantByMenuSuccess() throws Exception { + + // given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + List result = + IntStream.range(1, 10) + .mapToObj(idx -> new MockRestaurantDto()) + .collect(Collectors.toList()); + + given(restaurantService.findRestaurantByMenuId(anyLong(), anyLong())) + .willReturn(result); + + // when & then + mockMvc.perform(get("/api/restaurants?menuId=1") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + + } + + @DisplayName("FAILED - 메뉴별 식당 목록 응답 컨트롤러 테스트") + @Test + void findRestaurantByMenuFailed() throws Exception { + + //given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, + new Date(new Date().getTime() + 10000000)); + + given(restaurantService.findRestaurantByMenuId(anyLong(), anyLong())) + .willThrow( + new GlobalRuntimeException(RestaurantErrorCode.NOT_FOUND_RESTAURANT_BY_MENU)); + + // when & then + mockMvc.perform(get("/api/restaurants?menuId=1") + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value( + RestaurantErrorCode.NOT_FOUND_RESTAURANT_BY_MENU.getMessage())); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/restaurant/mock/MockNearRestaurantDto.java b/src/test/java/com/livable/server/restaurant/mock/MockNearRestaurantDto.java new file mode 100644 index 00000000..a3797e7f --- /dev/null +++ b/src/test/java/com/livable/server/restaurant/mock/MockNearRestaurantDto.java @@ -0,0 +1,6 @@ +package com.livable.server.restaurant.mock; + +import com.livable.server.restaurant.dto.RestaurantResponse; + +public class MockNearRestaurantDto extends RestaurantResponse.NearRestaurantDto { +} diff --git a/src/test/java/com/livable/server/restaurant/mock/MockRestaurantDto.java b/src/test/java/com/livable/server/restaurant/mock/MockRestaurantDto.java new file mode 100644 index 00000000..2928465f --- /dev/null +++ b/src/test/java/com/livable/server/restaurant/mock/MockRestaurantDto.java @@ -0,0 +1,7 @@ +package com.livable.server.restaurant.mock; + +import com.livable.server.restaurant.dto.RestaurantResponse.RestaurantsDto; + +public class MockRestaurantDto extends RestaurantsDto { + +} diff --git a/src/test/java/com/livable/server/restaurant/repository/RestaurantRepositoryTest.java b/src/test/java/com/livable/server/restaurant/repository/RestaurantRepositoryTest.java new file mode 100644 index 00000000..5315897d --- /dev/null +++ b/src/test/java/com/livable/server/restaurant/repository/RestaurantRepositoryTest.java @@ -0,0 +1,69 @@ +package com.livable.server.restaurant.repository; + +import com.livable.server.core.config.QueryDslConfig; +import com.livable.server.entity.Building; +import com.livable.server.entity.BuildingRestaurantMap; +import com.livable.server.entity.Restaurant; +import com.livable.server.entity.RestaurantCategory; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import javax.persistence.EntityManager; +import java.time.LocalTime; +import java.util.stream.IntStream; + +@DataJpaTest +@Import(QueryDslConfig.class) +class RestaurantRepositoryTest { + + @Autowired + RestaurantRepository restaurantRepository; + + @Autowired + EntityManager entityManager; + + @Autowired + BuildingRestaurantMapRepository buildingRestaurantMapRepository; + + @BeforeEach + void dataInit() { + Building building = Building.builder() + .name("63빌딩") + .scale("지하 3층, 지상 63층") + .representativeImageUrl("./thumbnailImage.jpg") + .endTime(LocalTime.of(10, 30)) + .startTime(LocalTime.of(18, 30)) + .parkingCostInformation("10분당 1000원") + .longitude("10.10.10.10") + .latitude("123.123.123") + .hasCafeteria(false) + .address("서울시 강남구 서초대로 61길 7, 392") + .subwayStation("석촌역") + .build(); + + entityManager.persist(building); + + IntStream.range(1, 10) + .forEach(idx -> { + Restaurant restaurant = Restaurant.builder() + .name("restaurant" + idx) + .address("서울시 강동구 태윤빌딩 " + (idx % 2 == 0 ? "지하" : "") + idx + "층") + .contact("contact" + idx) + .restaurantCategory(idx % 3 == 0 ? RestaurantCategory.CAFE : RestaurantCategory.RESTAURANT) + .restaurantUrl("url" + idx) + .thumbnailImageUrl("thumbnail" + idx) + .representativeCategory("ui category" + idx) + .build(); + restaurantRepository.save(restaurant); + BuildingRestaurantMap restaurantMap = BuildingRestaurantMap.builder() + .restaurant(restaurant) + .building(building) + .inBuilding(idx % 2 == 0) + .distance(idx * 80) + .build(); + buildingRestaurantMapRepository.save(restaurantMap); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/restaurant/service/RestaurantServiceTest.java b/src/test/java/com/livable/server/restaurant/service/RestaurantServiceTest.java new file mode 100644 index 00000000..b4e3a41b --- /dev/null +++ b/src/test/java/com/livable/server/restaurant/service/RestaurantServiceTest.java @@ -0,0 +1,180 @@ +package com.livable.server.restaurant.service; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.RestaurantCategory; +import com.livable.server.restaurant.domain.RandomGenerator; +import com.livable.server.restaurant.dto.RestaurantByMenuProjection; +import com.livable.server.restaurant.dto.RestaurantResponse; +import com.livable.server.restaurant.dto.RestaurantResponse.RestaurantsDto; +import com.livable.server.restaurant.repository.BuildingRestaurantMapRepository; +import com.livable.server.restaurant.repository.RestaurantGroupByMenuProjectionRepository; +import com.livable.server.restaurant.repository.RestaurantRepository; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.repository.VisitorRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class RestaurantServiceTest { + + @InjectMocks + RestaurantService restaurantService; + + @Mock + RandomGenerator randomGenerator; + @Mock + RestaurantRepository restaurantRepository; + @Mock + RestaurantGroupByMenuProjectionRepository restaurantGroupByMenuProjectionRepository; + @Mock + VisitorRepository visitorRepository; + @Mock + BuildingRestaurantMapRepository buildingRestaurantMapRepository; + + @DisplayName("RestaurantService.findNearRestaurantByVisitorIdAndRestaurantCategory 성공 테스트") + @Test + void findNearRestaurantByVisitorIdAndRestaurantCategorySuccessTest() { + // given + Long visitorId = 1L; + RestaurantCategory category = RestaurantCategory.RESTAURANT; + List dtos = IntStream.range(0, 5) + .mapToObj(idx -> new RestaurantResponse.NearRestaurantDto()) + .collect(Collectors.toList()); + + Pageable pageRequest = PageRequest.of(1, 5); + + given(visitorRepository.findBuildingIdById(anyLong())).willReturn(Optional.of(1L)); + given(buildingRestaurantMapRepository.countBuildingRestaurantMapByBuildingIdAndRestaurant_RestaurantCategory( + anyLong(), + any(RestaurantCategory.class) + )).willReturn(10); + given(randomGenerator.getRandom(anyInt())).willReturn(pageRequest); + given(restaurantRepository.findRestaurantByBuildingIdAndRestaurantCategory( + anyLong(), any(RestaurantCategory.class), any(Pageable.class) + )).willReturn(dtos); + + // when + List result = + restaurantService.findNearRestaurantByVisitorIdAndRestaurantCategory(visitorId, category); + + // then + then(visitorRepository).should(times(1)).findBuildingIdById(anyLong()); + then(buildingRestaurantMapRepository).should(times(1)) + .countBuildingRestaurantMapByBuildingIdAndRestaurant_RestaurantCategory( + anyLong(), + any(RestaurantCategory.class) + ); + then(randomGenerator).should(times(1)).getRandom(anyInt()); + then(restaurantRepository).should(times(1)) + .findRestaurantByBuildingIdAndRestaurantCategory( + anyLong(), + any(RestaurantCategory.class), + any(Pageable.class) + ); + assertThat(dtos).usingRecursiveComparison().isEqualTo(result); + } + + @DisplayName("RestaurantService.findNearRestaurantByVisitorIdAndRestaurantCategory 실패 테스트") + @Test + void findNearRestaurantByVisitorIdAndRestaurantCategoryFailTest() { + // given + Long visitorId = 1L; + RestaurantCategory category = RestaurantCategory.RESTAURANT; + List dtos = IntStream.range(0, 5) + .mapToObj(idx -> new RestaurantResponse.NearRestaurantDto()) + .collect(Collectors.toList()); + + given(visitorRepository.findBuildingIdById(anyLong())).willReturn(Optional.empty()); + + // when + GlobalRuntimeException globalRuntimeException = assertThrows( + GlobalRuntimeException.class, + () -> restaurantService.findNearRestaurantByVisitorIdAndRestaurantCategory(visitorId, category) + ); + + // then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.NOT_FOUND); + then(visitorRepository).should(times(1)).findBuildingIdById(anyLong()); + } + + @DisplayName("SUCCESS - 메뉴별 식당 목록 응답 서비스 테스트") + @Test + void findRestaurantByMenuSuccess() { + + //given + Long restaurantId = 1L; + String restaurantName = "레스토랑"; + String restaurantThumbnailUrl = "/restaurantImg.com"; + String address ="address for restaurant"; + Boolean inBuilding = true; + Integer distance = 100; + String review = "this is review"; + Integer tastePercentage = 30; + + List projections = List.of( + new RestaurantByMenuProjection(restaurantId, restaurantName, restaurantThumbnailUrl, + address, inBuilding, distance, review, tastePercentage) + ); + + given(restaurantGroupByMenuProjectionRepository.findRestaurantByMenuId(anyLong(), anyLong())) + .willReturn(projections); + + // when + List actual = + restaurantService.findRestaurantByMenuId(anyLong(), anyLong()); + + // then + assertAll( + () -> assertEquals(restaurantId, actual.get(0).getRestaurantId()), + () -> assertEquals(restaurantName, actual.get(0).getRestaurantName()), + () -> assertEquals(restaurantThumbnailUrl, actual.get(0).getRepresentativeImageUrl()), + () -> assertEquals(address, actual.get(0).getAddress()), + () -> assertEquals(inBuilding, actual.get(0).getInBuilding()), + () -> assertEquals(distance / 80, actual.get(0).getEstimatedTime()), + () -> assertEquals(review, actual.get(0).getReview()), + () -> assertEquals(tastePercentage, actual.get(0).getTastePercentage()) + ); + + } + + @DisplayName("FAIELD - 메뉴별 식당 목록 응답 서비스 테스트") + @Test + void findRestaurantByMenuFailed() { + + // given + + + // when + given(restaurantGroupByMenuProjectionRepository.findRestaurantByMenuId(anyLong(), anyLong())) + .willReturn(new ArrayList<>()); + + // then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + restaurantService.findRestaurantByMenuId(1L, 1L)); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/review/controller/MyReviewControllerTest.java b/src/test/java/com/livable/server/review/controller/MyReviewControllerTest.java new file mode 100644 index 00000000..21f20d62 --- /dev/null +++ b/src/test/java/com/livable/server/review/controller/MyReviewControllerTest.java @@ -0,0 +1,123 @@ +package com.livable.server.review.controller; + +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.review.dto.MyReviewResponse; +import com.livable.server.review.service.MyReviewService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.Date; + +@Import(TestConfig.class) +@WebMvcTest(MyReviewController.class) +class MyReviewControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider tokenProvider; + + @MockBean + private MyReviewService myReviewService; + + @Nested + @DisplayName("나의 레스토랑 리뷰 컨트롤러 단위 테스트") + class MyRestaurantReview { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/reviews/restaurant/1/members"; + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + MyReviewResponse.DetailDTO mockResponse + = MyReviewResponse.DetailDTO.builder().build(); + + Mockito.when(myReviewService.getMyRestaurantReview( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockResponse); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .header("Authorization", "Bearer " + token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()); + } + } + + @Nested + @DisplayName("나의 구내식당 리뷰 컨트롤러 단위 테스트") + class MyCafeteriaReview { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/reviews/cafeteria/1/members"; + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + + MyReviewResponse.DetailDTO mockResponse + = MyReviewResponse.DetailDTO.builder().build(); + + Mockito.when(myReviewService.getMyCafeteriaReview( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockResponse); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .header("Authorization", "Bearer " + token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()); + } + } + + @Nested + @DisplayName("나의 도시락 리뷰 컨트롤러 단위 테스트") + class MyLunchBoxReview { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String token = tokenProvider.createActorToken(ActorType.MEMBER, 1L, new Date(new Date().getTime() + 10000000)); + String uri = "/api/reviews/lunchbox/1/members"; + + MyReviewResponse.DetailDTO mockResponse + = MyReviewResponse.DetailDTO.builder().build(); + + Mockito.when(myReviewService.getMyLunchBoxReview( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockResponse); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .header("Authorization", "Bearer " + token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/review/controller/RestaurantReviewControllerTest.java b/src/test/java/com/livable/server/review/controller/RestaurantReviewControllerTest.java new file mode 100644 index 00000000..72f0ee2c --- /dev/null +++ b/src/test/java/com/livable/server/review/controller/RestaurantReviewControllerTest.java @@ -0,0 +1,177 @@ +package com.livable.server.review.controller; + +import com.livable.server.core.util.TestConfig; +import com.livable.server.review.dto.RestaurantReviewResponse; +import com.livable.server.review.service.RestaurantReviewService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.List; + +@Import(TestConfig.class) +@WebMvcTest(controllers = RestaurantReviewController.class) +class RestaurantReviewControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private RestaurantReviewService restaurantReviewService; + + @Nested + @DisplayName("레스토랑 리뷰 리스트 컨트롤러 단위 테스트") + class list { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/reviews/buildings/1"; + + List mockList = List.of( + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(1L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(2L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(3L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(4L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(5L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(6L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(7L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(8L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(9L).build(), + RestaurantReviewResponse.ListForBuildingDTO.builder().reviewId(10L).build() + ); + + Mockito.when(restaurantReviewService.getAllListForBuilding( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(Pageable.class)) + ).thenReturn(mockList); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.length()").value(10)); + } + } + + @Nested + @DisplayName("특정 음식점에 대한 레스토랑 리뷰 리스트 컨트롤러 단위 테스트") + class listForRestaurant { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/reviews/restaurants/1"; + + List mockList = List.of( + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(1L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(2L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(3L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(4L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(6L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(5L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(7L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(8L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(9L).build(), + RestaurantReviewResponse.ListForRestaurantDTO.builder().reviewId(10L).build() + ); + + Mockito.when(restaurantReviewService.getAllListForRestaurant( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(Pageable.class) + )).thenReturn(mockList); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.length()").value(10)); + } + } + + @Nested + @DisplayName("특정 메뉴에 대한 레스토랑 리뷰 리스트 컨트롤러 단위 테스트") + class listForMenu { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/reviews/menus/1"; + + List mockList = List.of( + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(1L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(2L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(3L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(4L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(6L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(5L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(7L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(8L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(9L).build(), + RestaurantReviewResponse.ListForMenuDTO.builder().reviewId(10L).build() + ); + Pageable pageable = PageRequest.of(0, 10); + + Mockito.when(restaurantReviewService + .getAllListForMenu(ArgumentMatchers.anyLong(), ArgumentMatchers.any(Pageable.class))) + .thenReturn(mockList); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.size()").value(10)); + } + } + + @Nested + @DisplayName("레스토랑 리뷰 상세 정보 컨트롤러 단위 테스트") + class Detail { + + @DisplayName("성공") + @Test + void success_Test() throws Exception { + // Given + String uri = "/api/reviews/1"; + + RestaurantReviewResponse.DetailDTO detailDTO + = RestaurantReviewResponse.DetailDTO.builder().build(); + + Mockito.when(restaurantReviewService.getDetail(ArgumentMatchers.anyLong())) + .thenReturn(detailDTO); + + // When + // Then + mockMvc.perform(MockMvcRequestBuilders.get(uri) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists()); + } + } +} diff --git a/src/test/java/com/livable/server/review/domain/MyReviewTest.java b/src/test/java/com/livable/server/review/domain/MyReviewTest.java new file mode 100644 index 00000000..06ce9ba5 --- /dev/null +++ b/src/test/java/com/livable/server/review/domain/MyReviewTest.java @@ -0,0 +1,134 @@ +package com.livable.server.review.domain; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Evaluation; +import com.livable.server.review.dto.MyReviewProjection; +import com.livable.server.review.dto.MyReviewResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("나의 리뷰 쿼리 결과 래퍼 클래스 테스트") +class MyReviewTest { + + + @DisplayName("정적 팩토리 메서드 테스트 (생성자 및 검증 메서드 간접 테스트)") + @Nested + class StaticFactoryMethod { + + @DisplayName("성공") + @Test + void success_Test() { + // Given + List myReviewProjections = List.of( + MyReviewProjection.builder() + .reviewTitle("TestTitle") + .reviewDescription("TestDescription") + .reviewCreatedAt(LocalDateTime.now()) + .reviewTaste(Evaluation.GOOD) + .reviewImg("TestImage") + .location("TestLocation") + .build() + ); + + // When + // Then + MyReview.from(myReviewProjections); + } + + @DisplayName("실패 - 비어있는 쿼리 결과로 생성") + @Test + void failure_Test_constructedEmptyList() { + // Given + List myReviewProjections = List.of(); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + MyReview.from(myReviewProjections)); + } + } + + @DisplayName("DTO 변환 테스트 (getTopOne, getImages 간접 테스트)") + @Nested + class ConvertToDTO { + + @DisplayName("성공 - 싱글 이미지") + @Test + void success_Test_SingleImage() { + // Given + List myReviewProjections = List.of( + MyReviewProjection.builder() + .reviewTitle("TestTitle") + .reviewDescription("TestDescription") + .reviewCreatedAt(LocalDateTime.now()) + .reviewTaste(Evaluation.GOOD) + .reviewImg("TestImage") + .location("TestLocation") + .build() + ); + + // When + MyReview myReview = MyReview.from(myReviewProjections); + MyReviewResponse.DetailDTO actual = myReview.toResponseDTO(); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals("TestTitle", actual.getReviewTitle()), + () -> Assertions.assertEquals(Evaluation.GOOD, actual.getReviewTaste()), + () -> Assertions.assertEquals(1, actual.getReviewImg().size()) + ); + } + + @DisplayName("성공 - 여러 이미지") + @Test + void success_Test_MultipleImage() { + // Given + List myReviewProjections = List.of( + MyReviewProjection.builder() + .reviewTitle("TestTitle") + .reviewDescription("TestDescription") + .reviewCreatedAt(LocalDateTime.now()) + .reviewTaste(Evaluation.GOOD) + .reviewImg("TestImage1") + .location("TestLocation") + .build(), + + MyReviewProjection.builder() + .reviewTitle("TestTitle") + .reviewDescription("TestDescription") + .reviewCreatedAt(LocalDateTime.now()) + .reviewTaste(Evaluation.GOOD) + .reviewImg("TestImage2") + .location("TestLocation") + .build(), + + MyReviewProjection.builder() + .reviewTitle("TestTitle") + .reviewDescription("TestDescription") + .reviewCreatedAt(LocalDateTime.now()) + .reviewTaste(Evaluation.GOOD) + .reviewImg("TestImage3") + .location("TestLocation") + .build() + ); + + // When + MyReview myReview = MyReview.from(myReviewProjections); + MyReviewResponse.DetailDTO actual = myReview.toResponseDTO(); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals("TestTitle", actual.getReviewTitle()), + () -> Assertions.assertEquals(Evaluation.GOOD, actual.getReviewTaste()), + () -> Assertions.assertEquals(3, actual.getReviewImg().size()) + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/review/service/MyReviewServiceTest.java b/src/test/java/com/livable/server/review/service/MyReviewServiceTest.java new file mode 100644 index 00000000..de7846e9 --- /dev/null +++ b/src/test/java/com/livable/server/review/service/MyReviewServiceTest.java @@ -0,0 +1,281 @@ +package com.livable.server.review.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.review.dto.MyReviewProjection; +import com.livable.server.review.dto.MyReviewResponse; +import com.livable.server.review.repository.MyReviewRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class MyReviewServiceTest { + + @Mock + private MyReviewRepository myReviewRepository; + + @InjectMocks + private MyReviewService myReviewService; + + @Nested + @DisplayName("나의 레스토랑 리뷰 서비스 단위 테스트") + class MyRestaurantReview { + + @DisplayName("성공 - DTO 변환 테스트 (싱글 이미지)") + @Test + void success_Test_SingleImg() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + String reviewDescription = "맛있오"; + String imgUrl = "mockImage.jpg"; + + List mockList = List.of( + MyReviewProjection.builder().reviewDescription(reviewDescription).reviewImg(imgUrl).build() + ); + + Mockito.when(myReviewRepository.findRestaurantReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockList); + + + // When + MyReviewResponse.DetailDTO actual = myReviewService.getMyRestaurantReview(reviewId, memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(reviewDescription, actual.getReviewDescription()), + () -> Assertions.assertEquals(1, actual.getReviewImg().size()), + () -> Assertions.assertEquals(imgUrl, actual.getReviewImg().get(0)) + ); + } + + @DisplayName("성공 - DTO 변환 테스트 (여러 이미지)") + @Test + void success_Test_MultipleImg() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + String imgUrl1 = "mockImage1.jpg"; + String imgUrl2 = "mockImage2.jpg"; + + List mockList = List.of( + MyReviewProjection.builder().reviewImg(imgUrl1).build(), + MyReviewProjection.builder().reviewImg(imgUrl2).build() + ); + + Mockito.when(myReviewRepository.findRestaurantReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockList); + + + // When + MyReviewResponse.DetailDTO actual = myReviewService.getMyRestaurantReview(reviewId, memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(2, actual.getReviewImg().size()), + () -> Assertions.assertEquals(imgUrl1, actual.getReviewImg().get(0)), + () -> Assertions.assertEquals(imgUrl2, actual.getReviewImg().get(1)) + ); + } + + @DisplayName("실패 - DTO변환 오류") + @Test + void failure_Test_FailedConvertToDTO() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + + Mockito.when(myReviewRepository.findRestaurantReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(new ArrayList<>()); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + myReviewService.getMyRestaurantReview(reviewId, memberId)); + } + } + + @Nested + @DisplayName("나의 구내식당 리뷰 서비스 단위 테스트") + class MyCafeteriaReview { + + @DisplayName("성공 - DTO 변환 테스트 (싱글 이미지)") + @Test + void success_Test_SingleImg() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + String reviewDescription = "맛있오"; + String imgUrl = "mockImage.jpg"; + + List mockList = List.of( + MyReviewProjection.builder().reviewDescription(reviewDescription).reviewImg(imgUrl).build() + ); + + Mockito.when(myReviewRepository.findCafeteriaReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockList); + + + // When + MyReviewResponse.DetailDTO actual = myReviewService.getMyCafeteriaReview(reviewId, memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(reviewDescription, actual.getReviewDescription()), + () -> Assertions.assertEquals(1, actual.getReviewImg().size()), + () -> Assertions.assertEquals(imgUrl, actual.getReviewImg().get(0)) + ); + } + + @DisplayName("성공 - DTO 변환 테스트 (여러 이미지)") + @Test + void success_Test_MultipleImg() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + String imgUrl1 = "mockImage1.jpg"; + String imgUrl2 = "mockImage2.jpg"; + + List mockList = List.of( + MyReviewProjection.builder().reviewImg(imgUrl1).build(), + MyReviewProjection.builder().reviewImg(imgUrl2).build() + ); + + Mockito.when(myReviewRepository.findCafeteriaReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockList); + + + // When + MyReviewResponse.DetailDTO actual = myReviewService.getMyCafeteriaReview(reviewId, memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(2, actual.getReviewImg().size()), + () -> Assertions.assertEquals(imgUrl1, actual.getReviewImg().get(0)), + () -> Assertions.assertEquals(imgUrl2, actual.getReviewImg().get(1)) + ); + } + + @DisplayName("실패 - DTO변환 오류") + @Test + void failure_Test_FailedConvertToDTO() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + + Mockito.when(myReviewRepository.findCafeteriaReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(new ArrayList<>()); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + myReviewService.getMyCafeteriaReview(reviewId, memberId)); + } + } + + @Nested + @DisplayName("나의 도시락 리뷰 서비스 단위 테스트") + class MyLunchBoxReview { + + @DisplayName("성공 - DTO 변환 테스트 (싱글 이미지)") + @Test + void success_Test_SingleImg() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + String reviewDescription = "맛있오"; + String imgUrl = "mockImage.jpg"; + + List mockList = List.of( + MyReviewProjection.builder().reviewDescription(reviewDescription).reviewImg(imgUrl).build() + ); + + Mockito.when(myReviewRepository.findLunchBoxReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockList); + + + // When + MyReviewResponse.DetailDTO actual = myReviewService.getMyLunchBoxReview(reviewId, memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(reviewDescription, actual.getReviewDescription()), + () -> Assertions.assertEquals(1, actual.getReviewImg().size()), + () -> Assertions.assertEquals(imgUrl, actual.getReviewImg().get(0)) + ); + } + + @DisplayName("성공 - DTO 변환 테스트 (여러 이미지)") + @Test + void success_Test_MultipleImg() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + String imgUrl1 = "mockImage1.jpg"; + String imgUrl2 = "mockImage2.jpg"; + + List mockList = List.of( + MyReviewProjection.builder().reviewImg(imgUrl1).build(), + MyReviewProjection.builder().reviewImg(imgUrl2).build() + ); + + Mockito.when(myReviewRepository.findLunchBoxReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(mockList); + + + // When + MyReviewResponse.DetailDTO actual = myReviewService.getMyLunchBoxReview(reviewId, memberId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(2, actual.getReviewImg().size()), + () -> Assertions.assertEquals(imgUrl1, actual.getReviewImg().get(0)), + () -> Assertions.assertEquals(imgUrl2, actual.getReviewImg().get(1)) + ); + } + + @DisplayName("실패 - DTO변환 오류") + @Test + void failure_Test_FailedConvertToDTO() { + // Given + Long reviewId = 1L; + Long memberId = 1L; + + Mockito.when(myReviewRepository.findLunchBoxReviewByReviewId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.anyLong() + )).thenReturn(new ArrayList<>()); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + myReviewService.getMyLunchBoxReview(reviewId, memberId)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/review/service/RestaurantReviewServiceTest.java b/src/test/java/com/livable/server/review/service/RestaurantReviewServiceTest.java new file mode 100644 index 00000000..34ba551a --- /dev/null +++ b/src/test/java/com/livable/server/review/service/RestaurantReviewServiceTest.java @@ -0,0 +1,245 @@ +package com.livable.server.review.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ImageSeparator; +import com.livable.server.review.dto.Projection; +import com.livable.server.review.dto.RestaurantReviewProjection; +import com.livable.server.review.dto.RestaurantReviewResponse; +import com.livable.server.review.repository.RestaurantReviewProjectionRepository; +import com.livable.server.review.repository.RestaurantReviewRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.ArrayList; +import java.util.List; + +import static com.livable.server.review.dto.RestaurantReviewResponse.*; +import static com.livable.server.review.dto.RestaurantReviewResponse.ListForMenuDTO; + +@ExtendWith(MockitoExtension.class) +class RestaurantReviewServiceTest { + + @Mock + private RestaurantReviewRepository restaurantReviewRepository; + + @Mock + private RestaurantReviewProjectionRepository restaurantReviewProjectionRepository; + + @Mock + private ImageSeparator imageSeparator; + + @InjectMocks + private RestaurantReviewService restaurantReviewService; + + @Nested + @DisplayName("레스토랑 리뷰 리스트 서비스 단위 테스트") + class listForBuilding { + + @DisplayName("성공") + @Test + void success_Test() { + // Given + Long buildingId = 1L; + + List mockList = List.of( + RestaurantReviewProjection.builder().reviewId(1L).build(), + RestaurantReviewProjection.builder().reviewId(2L).build(), + RestaurantReviewProjection.builder().reviewId(3L).build(), + RestaurantReviewProjection.builder().reviewId(4L).build(), + RestaurantReviewProjection.builder().reviewId(5L).build(), + RestaurantReviewProjection.builder().reviewId(6L).build(), + RestaurantReviewProjection.builder().reviewId(7L).build(), + RestaurantReviewProjection.builder().reviewId(8L).build(), + RestaurantReviewProjection.builder().reviewId(9L).build(), + RestaurantReviewProjection.builder().reviewId(10L).build() + ); + Pageable pageable = PageRequest.of(0, 10); + + Mockito.when(imageSeparator.separateConcatenatedImages(null)) + .thenReturn(new ArrayList<>()); + + Mockito.when(restaurantReviewProjectionRepository.findRestaurantReviewProjectionByBuildingId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(Pageable.class) + )).thenReturn(mockList); + + // When + List actual = + restaurantReviewService.getAllListForBuilding(buildingId, pageable); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(10, actual.size()) + ); + } + } + + @Nested + @DisplayName("특정 음식점에 대한 리뷰 리스트 서비스 단위 테스트") + class listForRestaurant { + + @DisplayName("성공") + @Test + void success_Test() { + // Given + Long restaurantId = 1L; + + List mockList = List.of( + RestaurantReviewProjection.builder().reviewId(1L).build(), + RestaurantReviewProjection.builder().reviewId(2L).build(), + RestaurantReviewProjection.builder().reviewId(3L).build(), + RestaurantReviewProjection.builder().reviewId(4L).build(), + RestaurantReviewProjection.builder().reviewId(5L).build(), + RestaurantReviewProjection.builder().reviewId(6L).build(), + RestaurantReviewProjection.builder().reviewId(7L).build(), + RestaurantReviewProjection.builder().reviewId(8L).build(), + RestaurantReviewProjection.builder().reviewId(9L).build(), + RestaurantReviewProjection.builder().reviewId(10L).build() + ); + Pageable pageable = PageRequest.of(0, 10); + + Mockito.when(imageSeparator.separateConcatenatedImages(null)) + .thenReturn(new ArrayList<>()); + + Mockito.when(restaurantReviewProjectionRepository.findRestaurantReviewProjectionByRestaurantId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(Pageable.class) + )).thenReturn(mockList); + + // When + List actual = + restaurantReviewService.getAllListForRestaurant(restaurantId, pageable); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(10, actual.size()) + ); + } + } + + @Nested + @DisplayName("특정 메뉴에 대한 레스토랑 리뷰 리스트 서비스 단위 테스트") + class listForMenu { + + @DisplayName("성공") + @Test + void success_Test() { + // Given + Long menuId = 1L; + + List mockList = List.of( + RestaurantReviewProjection.builder().reviewId(1L).build(), + RestaurantReviewProjection.builder().reviewId(2L).build(), + RestaurantReviewProjection.builder().reviewId(3L).build(), + RestaurantReviewProjection.builder().reviewId(4L).build(), + RestaurantReviewProjection.builder().reviewId(5L).build(), + RestaurantReviewProjection.builder().reviewId(6L).build(), + RestaurantReviewProjection.builder().reviewId(7L).build(), + RestaurantReviewProjection.builder().reviewId(8L).build(), + RestaurantReviewProjection.builder().reviewId(9L).build(), + RestaurantReviewProjection.builder().reviewId(10L).build() + ); + Pageable pageable = PageRequest.of(0, 10); + + Mockito.when(restaurantReviewProjectionRepository.findRestaurantReviewProjectionByMenuId( + ArgumentMatchers.anyLong(), + ArgumentMatchers.any(Pageable.class) + )).thenReturn(mockList); + + // When + List actual = + restaurantReviewService.getAllListForMenu(menuId, pageable); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals(10, actual.size()) + ); + } + } + + @Nested + @DisplayName("레스토랑 리뷰 상세 정보 서비스 단위 테스트") + class Detail { + + @DisplayName("성공 - 싱글 이미지") + @Test + void success_Test_SingleImage() { + // Given + Long reviewId = 1L; + + List mockResult = List.of( + Projection.RestaurantReview.builder() + .images("TestImages") + .reviewDescription("TestDescription") + .build() + ); + + Mockito.when(restaurantReviewRepository.findRestaurantReviewById(ArgumentMatchers.anyLong())) + .thenReturn(mockResult); + // When + DetailDTO actual = restaurantReviewService.getDetail(reviewId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals("TestDescription", actual.getReviewDescription()), + () -> Assertions.assertEquals(1, actual.getReviewImages().size()) + ); + } + + @DisplayName("성공 - 여러 이미지") + @Test + void success_Test_MultipleImage() { + // Given + Long reviewId = 1L; + + List mockResult = List.of( + Projection.RestaurantReview.builder() + .images("TestImage1") + .reviewDescription("TestDescription") + .build(), + Projection.RestaurantReview.builder() + .images("TestImage2") + .reviewDescription("TestDescription") + .build() + ); + + Mockito.when(restaurantReviewRepository.findRestaurantReviewById(ArgumentMatchers.anyLong())) + .thenReturn(mockResult); + // When + DetailDTO actual = restaurantReviewService.getDetail(reviewId); + + // Then + Assertions.assertAll( + () -> Assertions.assertEquals("TestDescription", actual.getReviewDescription()), + () -> Assertions.assertEquals(2, actual.getReviewImages().size()) + ); + } + + @DisplayName("실패 - DTO변환 오류") + @Test + void failure_Test_FailedConvertToDTO() { + // Given + Long reviewId = 1L; + + Mockito.when(restaurantReviewRepository.findRestaurantReviewById(ArgumentMatchers.anyLong())) + .thenReturn(new ArrayList<>()); + + // When + // Then + Assertions.assertThrows(GlobalRuntimeException.class, () -> + restaurantReviewService.getDetail(reviewId)); + } + } +} diff --git a/src/test/java/com/livable/server/visitation/controller/VisitationControllerTest.java b/src/test/java/com/livable/server/visitation/controller/VisitationControllerTest.java new file mode 100644 index 00000000..8ce4bade --- /dev/null +++ b/src/test/java/com/livable/server/visitation/controller/VisitationControllerTest.java @@ -0,0 +1,211 @@ +package com.livable.server.visitation.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.livable.server.core.exception.ErrorCode; +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.core.util.ActorType; +import com.livable.server.core.util.JwtTokenProvider; +import com.livable.server.core.util.TestConfig; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationRequest; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.mock.MockDetailInformationDto; +import com.livable.server.visitation.mock.MockRegisterParkingDto; +import com.livable.server.visitation.mock.MockValidateQrCodeDto; +import com.livable.server.visitation.service.VisitationFacadeService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Date; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Import(TestConfig.class) +@WebMvcTest(VisitationController.class) +class VisitationControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + JwtTokenProvider tokenProvider; + + @MockBean + private VisitationFacadeService visitationFacadeService; + + @DisplayName("[GET][/api/visitation] - 방문증 상세 정보 정상 응답") + @Test + void findVisitationDetailInformationSuccessTest() throws Exception { + + // Given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + MockDetailInformationDto mockDetailInformationDto = new MockDetailInformationDto(); + given(visitationFacadeService.findVisitationDetailInformation(anyLong())).willReturn(mockDetailInformationDto); + + + // When + ResultActions resultActions = mockMvc.perform( + get("/api/visitation") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + ); + + // Then + resultActions.andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data").exists()); + + then(visitationFacadeService).should(times(1)).findVisitationDetailInformation(anyLong()); + } + + @DisplayName("[GET][/api/visitation/qr] - QR을 생성 정상 응답") + @Test + void createQrCodeSuccessTest() throws Exception { + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + String base64QrCode = "base64QrCode임 ㅋㅋ"; + VisitationResponse.Base64QrCode result = VisitationResponse.Base64QrCode.of(base64QrCode); + + // given + given(visitationFacadeService.createQrCode(1L)).willReturn(result); + + // when + ResultActions resultActions = mockMvc.perform( + get("/api/visitation/qr") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.qr").value(base64QrCode)); + + then(visitationFacadeService).should(times(1)).createQrCode(1L); + } + + @DisplayName("[POST][/api/visitation/qr] - QR인증 성공") + @Test + void validateQrCodeSuccess() throws Exception { + // given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + String qr = "qr"; + VisitationRequest.ValidateQrCodeDto validateQrCodeSuccessMockRequest = new MockValidateQrCodeDto(qr); + + willDoNothing().given(visitationFacadeService).validateQrCode(anyString(), anyLong()); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/visitation/qr") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validateQrCodeSuccessMockRequest)) + ); + + // then + resultActions.andExpect(status().isOk()); + + then(visitationFacadeService).should(times(1)).validateQrCode(anyString(), anyLong()); + } + + @DisplayName("[POST][/api/visitation/qr] - QR인증 성공") + @Test + void validateQrCodeFail() throws Exception { + // given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + String qr = "qr"; + ErrorCode errorCode = VisitationErrorCode.INVALID_QR_PERIOD; + String errorMessage = errorCode.getMessage(); + VisitationRequest.ValidateQrCodeDto validateQrCodeSuccessMockRequest = new MockValidateQrCodeDto(qr); + + + willThrow(new GlobalRuntimeException(errorCode)) + .given(visitationFacadeService) + .validateQrCode(anyString(), anyLong()); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/visitation/qr") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validateQrCodeSuccessMockRequest)) + ); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(errorMessage)); + + then(visitationFacadeService).should(times(1)).validateQrCode(anyString(), anyLong()); + } + + @DisplayName("[GET][/api/visitation/parking] - 차량 조회 성공") + @Test + void findCarNumberSuccess() throws Exception { + // given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + String carNumber = "12가1234"; + VisitationResponse.CarNumber result = VisitationResponse.CarNumber.of(1L, carNumber); + + + given(visitationFacadeService.findCarNumber(1L)).willReturn(result); + + // when + ResultActions resultActions = mockMvc.perform( + get("/api/visitation/parking") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.visitorId").isNumber()) + .andExpect(jsonPath("$.data.carNumber").isString()) + .andExpect(jsonPath("$.data.visitorId").value(1)) + .andExpect(jsonPath("$.data.carNumber").value("12가1234")); + + then(visitationFacadeService) + .should(times(1)) + .findCarNumber(1L); + } + + @DisplayName("[POST][/api/visitation/parking] - 차량 등록 성공") + @Test + void registerParking() throws Exception { + // given + String token = tokenProvider.createActorToken(ActorType.VISITOR, 1L, new Date(new Date().getTime() + 10000000)); + String carNumber = "12가1234"; + MockRegisterParkingDto mockRegisterParkingDto = new MockRegisterParkingDto(carNumber); + Long visitorId = 1L; + + + willDoNothing().given(visitationFacadeService).registerParking(visitorId, mockRegisterParkingDto.getCarNumber()); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/visitation/parking") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mockRegisterParkingDto)) + ); + + // then + resultActions.andExpect(status().isCreated()); + + then(visitationFacadeService) + .should(times(1)) + .registerParking(visitorId, mockRegisterParkingDto.getCarNumber()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/domain/QrCodeDecoderTest.java b/src/test/java/com/livable/server/visitation/domain/QrCodeDecoderTest.java new file mode 100644 index 00000000..cf4a139b --- /dev/null +++ b/src/test/java/com/livable/server/visitation/domain/QrCodeDecoderTest.java @@ -0,0 +1,75 @@ +package com.livable.server.visitation.domain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.visitation.mock.MockQrPayload; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class QrCodeDecoderTest { + + private static String qrCode; + + @InjectMocks + QrCodeDecoder qrCodeDecoder; + + @Mock + ObjectMapper objectMapper; + + @BeforeAll + static void beforeAll() { + QrCodeManager qrCodeManager = new QrCodeManager( + new QrCodeEncoder(new ObjectMapper()), new QrCodeDecoder(new ObjectMapper()) + ); + qrCode = qrCodeManager.createQrCode(LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1)); + } + + @DisplayName("QrCodeDecoder.getQrPayload 성공 테스트") + @Test + void getQrPayloadSuccessTest() throws JsonProcessingException { + + // given + LocalDateTime startDate = LocalDateTime.now().minusDays(1); + LocalDateTime endDate = LocalDateTime.now().plusDays(1); + QrPayload qrPayload = new MockQrPayload(startDate, endDate); + given(objectMapper.readValue(anyString(), any(Class.class))).willReturn(qrPayload); + + // when + QrPayload result = qrCodeDecoder.getQrPayload(qrCode); + + + // then + assertThat(result).isEqualTo(qrPayload); + } + + @DisplayName("QrCodeDecoder.getQrPayload 실패 테스트") + @Test + void getQrPayloadFailTest() throws JsonProcessingException { + + // given + given(objectMapper.readValue(anyString(), any(Class.class))).willThrow(JsonProcessingException.class); + + // when + GlobalRuntimeException globalRuntimeException = + assertThrows(GlobalRuntimeException.class, () -> qrCodeDecoder.getQrPayload(qrCode)); + + + // then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.OBJECTMAPPER); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/domain/QrCodeEncoderTest.java b/src/test/java/com/livable/server/visitation/domain/QrCodeEncoderTest.java new file mode 100644 index 00000000..6956851b --- /dev/null +++ b/src/test/java/com/livable/server/visitation/domain/QrCodeEncoderTest.java @@ -0,0 +1,76 @@ +package com.livable.server.visitation.domain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.MockedStatic; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.awt.image.BufferedImage; +import java.time.LocalDateTime; +import java.util.HashMap; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class QrCodeEncoderTest { + + @InjectMocks + QrCodeEncoder qrCodeEncoder; + + @Spy + ObjectMapper objectMapper; + + @DisplayName("QrCodeEncoder.createQrCodeBufferdImage 성공 테스트") + @Test + void createQrCodeBufferdImageSuceessTest() throws JsonProcessingException { + // given + LocalDateTime startDate = LocalDateTime.now().minusDays(1); + LocalDateTime endDate = LocalDateTime.now().plusDays(1); + BufferedImage bufferedImage = new BufferedImage(10, 10, 10); + MockedStatic imageWriter = mockStatic(MatrixToImageWriter.class); + given(MatrixToImageWriter.toBufferedImage(any(BitMatrix.class))).willReturn(bufferedImage); + + // when + BufferedImage qrCodeBufferdImage = qrCodeEncoder.createQrCodeBufferdImage(startDate, endDate); + + // then + assertThat(qrCodeBufferdImage).isEqualTo(bufferedImage); + then(objectMapper).should(times(1)).registerModule(any(JavaTimeModule.class)); + then(objectMapper).should(times(1)).writeValueAsString(any(HashMap.class)); + + imageWriter.close(); + } + + @DisplayName("QrCodeEncoder.createQrCodeBufferdImage 실패 테스트") + @Test + void createQrCodeBufferdImageFailTest() throws JsonProcessingException { + // given + LocalDateTime startDate = LocalDateTime.now().minusDays(1); + LocalDateTime endDate = LocalDateTime.now().plusDays(1); + given(objectMapper.writeValueAsString(any(HashMap.class))).willThrow(JsonProcessingException.class); + + // when + RuntimeException runtimeException = + assertThrows(RuntimeException.class, () -> qrCodeEncoder.createQrCodeBufferdImage(startDate, endDate)); + + // then + assertThat(runtimeException.getStackTrace()).isNotNull(); + then(objectMapper).should(times(1)).registerModule(any(JavaTimeModule.class)); + then(objectMapper).should(times(1)).writeValueAsString(any(HashMap.class)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/domain/QrCodeManagerTest.java b/src/test/java/com/livable/server/visitation/domain/QrCodeManagerTest.java new file mode 100644 index 00000000..fb8a5049 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/domain/QrCodeManagerTest.java @@ -0,0 +1,163 @@ +package com.livable.server.visitation.domain; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.visitation.mock.MockQrPayload; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.awt.image.BufferedImage; +import java.time.LocalDateTime; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class QrCodeManagerTest { + + @InjectMocks + QrCodeManager qrCodeManager; + + @Mock + QrCodeEncoder qrCodeEncoder; + + @Mock + QrCodeDecoder qrCodeDecoder; + + @DisplayName("QrCodeManager.createQrCode 성공 테스트") + @Test + void createQrCodeSuccessTest() { + + // Given + String qrCode = "QR_CODE"; + LocalDateTime startDate = LocalDateTime.now().minusDays(1); + LocalDateTime endDate = LocalDateTime.now().plusDays(1); + BufferedImage bufferedImage = new BufferedImage(10, 10, 1); + given(qrCodeEncoder.createQrCodeBufferdImage(any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(bufferedImage); + given(qrCodeEncoder.encodeQrcodeToBase64(any(BufferedImage.class))).willReturn(qrCode); + + // When + String result = qrCodeManager.createQrCode(startDate, endDate); + + // Then + assertThat(result).isEqualTo(qrCode); + then(qrCodeEncoder).should(times(1)).createQrCodeBufferdImage(any(LocalDateTime.class), any(LocalDateTime.class)); + then(qrCodeEncoder).should(times(1)).encodeQrcodeToBase64(any(BufferedImage.class)); + } + + @DisplayName("QrCodeManager.createQrCode 실패 테스트_1") + @Test + void createQrCodeFailTest_1() { + + // Given + LocalDateTime startDate = LocalDateTime.now().plusDays(1); + LocalDateTime endDate = LocalDateTime.now().minusDays(1); + + // When + GlobalRuntimeException globalRuntimeException = assertThrows(GlobalRuntimeException.class, () -> qrCodeManager.createQrCode(startDate, endDate)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.INVALID_PERIOD); + } + + @DisplayName("QrCodeManager.createQrCode 실패 테스트_2") + @Test + void createQrCodeFailTest_2() { + + // Given + LocalDateTime startDate = LocalDateTime.now().minusDays(2); + LocalDateTime endDate = LocalDateTime.now().minusDays(1); + + // When + GlobalRuntimeException globalRuntimeException = assertThrows(GlobalRuntimeException.class, () -> qrCodeManager.createQrCode(startDate, endDate)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.INVALID_QR_PERIOD); + } + + @DisplayName("QrCodeManager.validateQrCode 성공 테스트") + @Test + void validateQrCodeSuccessTest() { + + // Given + String qrCode = "QR_CODE"; + LocalDateTime startDate = LocalDateTime.now().minusDays(1); + LocalDateTime endDate = LocalDateTime.now().plusDays(1); + QrPayload qrPayload = new MockQrPayload(startDate, endDate); + given(qrCodeDecoder.getQrPayload(anyString())).willReturn(qrPayload); + + // When + qrCodeManager.validateQrCode(qrCode); + + // Then + then(qrCodeDecoder).should(times(1)).getQrPayload(anyString()); + } + + @DisplayName("QrCodeManager.validateQrCode 실패 테스트_1") + @Test + void validateQrCodeFailTest_1() { + + // Given + String qrCode = "QR_CODE"; + LocalDateTime startDate = LocalDateTime.now().plusDays(1); + LocalDateTime endDate = LocalDateTime.now().minusDays(1); + QrPayload qrPayload = new MockQrPayload(startDate, endDate); + given(qrCodeDecoder.getQrPayload(anyString())).willReturn(qrPayload); + + // When + GlobalRuntimeException globalRuntimeException = + assertThrows(GlobalRuntimeException.class, () -> qrCodeManager.validateQrCode(qrCode)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.INVALID_PERIOD); + then(qrCodeDecoder).should(times(1)).getQrPayload(anyString()); + } + + @DisplayName("QrCodeManager.validateQrCode 실패 테스트_2") + @Test + void validateQrCodeFailTest_2() { + + // Given + String qrCode = "QR_CODE"; + LocalDateTime startDate = LocalDateTime.now().minusDays(2); + LocalDateTime endDate = LocalDateTime.now().minusDays(1); + QrPayload qrPayload = new MockQrPayload(startDate, endDate); + given(qrCodeDecoder.getQrPayload(anyString())).willReturn(qrPayload); + + // When + GlobalRuntimeException globalRuntimeException = + assertThrows(GlobalRuntimeException.class, () -> qrCodeManager.validateQrCode(qrCode)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.INVALID_QR_PERIOD); + then(qrCodeDecoder).should(times(1)).getQrPayload(anyString()); + } + + @DisplayName("QrCodeManager.validateQrCode 실패 테스트_3") + @Test + void validateQrCodeFailTest_3() { + + // Given + String qrCode = "QR_CODE"; + LocalDateTime startDate = LocalDateTime.now().minusDays(2); + LocalDateTime endDate = LocalDateTime.now().minusDays(1); + QrPayload qrPayload = new MockQrPayload(startDate, endDate); + given(qrCodeDecoder.getQrPayload(anyString())).willReturn(qrPayload); + + // When + GlobalRuntimeException globalRuntimeException = + assertThrows(GlobalRuntimeException.class, () -> qrCodeManager.validateQrCode(qrCode)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.INVALID_QR_PERIOD); + then(qrCodeDecoder).should(times(1)).getQrPayload(anyString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/dto/VisitationRequestTest.java b/src/test/java/com/livable/server/visitation/dto/VisitationRequestTest.java new file mode 100644 index 00000000..bd55e3c5 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/dto/VisitationRequestTest.java @@ -0,0 +1,155 @@ +package com.livable.server.visitation.dto; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.livable.server.visitation.domain.VisitationValidationMessage; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +class VisitationRequestTest { + + private static Validator validator; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + + @BeforeAll + public static void init() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + + @DisplayName("VisitationRequest.RegisterParkingDto carNumber Pattern 성공 테스트") + @ValueSource(strings = {"12가1234", "12흫3456", "123핡0000"}) + @ParameterizedTest(name = "[{index}] 차량번호: {0}") + void carNumberPatternSuccessTest(String carNumber) throws JsonProcessingException { + VisitationRequest.RegisterParkingDto registerParkingDto = registerParkingDtoSerialize(carNumber); + + Set> validate = validator.validate(registerParkingDto); + + assertThat(validate.size()).isEqualTo(0); + } + + @DisplayName("VisitationRequest.RegisterParkingDto carNumber Pattern 실패 테스트") + @ValueSource(strings = {"123", "124", "가가가각가"}) + @ParameterizedTest(name = "[{index}] 차량번호: {0}") + void carNumberPatternFailTest(String carNumber) throws JsonProcessingException { + VisitationRequest.RegisterParkingDto registerParkingDto = registerParkingDtoSerialize(carNumber); + + Set> validate = validator.validate(registerParkingDto); + List errorMessages = getErrorMessages(validate); + + assertThat(validate.size()).isEqualTo(1); + assertThat(errorMessages.contains(VisitationValidationMessage.INVALID_CAR_NUMBER)).isTrue(); + } + + @DisplayName("VisitationRequest.RegisterParkingDto carNumber NotBlank 성공 테스트_1") + @ValueSource(strings = {"abcde", "124", "가가가각가"}) + @ParameterizedTest(name = "[{index}] 차량번호: {0}") + void carNumberNotBlankSuccess(String carNumber) throws JsonProcessingException { + VisitationRequest.RegisterParkingDto registerParkingDto = registerParkingDtoSerialize(carNumber); + + Set> validate = validator.validate(registerParkingDto); + + List messages = getErrorMessages(validate); + + assertThat(validate.size()).isEqualTo(1); + assertThat(messages.contains(VisitationValidationMessage.INVALID_CAR_NUMBER)).isTrue(); + } + + @DisplayName("VisitationRequest.RegisterParkingDto carNumber NotBlank 실패 테스트_1") + @ValueSource(strings = {"", " "}) + @ParameterizedTest(name = "[{index}] 차량번호: {0}") + void carNumberNotBlankFailTest_1(String carNumber) throws JsonProcessingException { + VisitationRequest.RegisterParkingDto registerParkingDto = registerParkingDtoSerialize(carNumber); + + Set> validate = validator.validate(registerParkingDto); + + List errorMessages = getErrorMessages(validate); + + assertThat(validate.size()).isEqualTo(2); + assertThat(errorMessages.contains(VisitationValidationMessage.NOT_BLANK)).isTrue(); + } + + @DisplayName("VisitationRequest.RegisterParkingDto carNumber NotBlank 실패 테스트_2") + @NullSource + @ParameterizedTest + void carNumberNotBlankFailTest_2(String carNumber) throws JsonProcessingException { + VisitationRequest.RegisterParkingDto registerParkingDto = registerParkingDtoSerialize(carNumber); + + Set> validate = validator.validate(registerParkingDto); + + List errorMessages = getErrorMessages(validate); + + assertThat(validate.size()).isEqualTo(1); + assertThat(errorMessages.contains(VisitationValidationMessage.NOT_BLANK)).isTrue(); + } + + @DisplayName("VisitationRequest.ValidateQrCodeDto qr NotBlank 성공 테스트") + @Test + void qrNotBlankSuccess() throws JsonProcessingException { + VisitationRequest.ValidateQrCodeDto validateQrCodeDto = validateQrCodeDtoSerialize("123"); + + Set> validate = validator.validate(validateQrCodeDto); + + List errorMessages = getErrorMessages(validate); + + assertThat(validate.size()).isEqualTo(0); + } + + @DisplayName("VisitationRequest.ValidateQrCodeDto qr NotBlank 실패 테스트") + @NullSource + @ParameterizedTest + void qrNotBlankSuccess(String qr) throws JsonProcessingException { + VisitationRequest.ValidateQrCodeDto validateQrCodeDto = validateQrCodeDtoSerialize(qr); + + Set> validate = validator.validate(validateQrCodeDto); + + List errorMessages = getErrorMessages(validate); + + assertThat(validate.size()).isEqualTo(1); + assertThat(errorMessages.contains(VisitationValidationMessage.NOT_BLANK)).isTrue(); + } + + private VisitationRequest.ValidateQrCodeDto validateQrCodeDtoSerialize(String qr) throws JsonProcessingException { + + if (qr == null) { + return new VisitationRequest.ValidateQrCodeDto(); + } + + String template = "{\"qr\":\"" + qr + "\"}"; + + return objectMapper.readValue(template, VisitationRequest.ValidateQrCodeDto.class); + } + + private VisitationRequest.RegisterParkingDto registerParkingDtoSerialize(String carNumber) throws JsonProcessingException { + + if (carNumber == null) { + return new VisitationRequest.RegisterParkingDto(); + } + + String template = "{\"carNumber\":\"" + carNumber + "\"}"; + + return objectMapper.readValue(template, VisitationRequest.RegisterParkingDto.class); + } + + private static > List getErrorMessages(Set validate) { + return validate.stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/mock/MockDetailInformationDto.java b/src/test/java/com/livable/server/visitation/mock/MockDetailInformationDto.java new file mode 100644 index 00000000..8eb6e2d1 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/mock/MockDetailInformationDto.java @@ -0,0 +1,54 @@ +package com.livable.server.visitation.mock; + +import com.livable.server.visitation.domain.PlaceType; +import com.livable.server.visitation.dto.VisitationResponse; + +import java.time.LocalDate; +import java.time.LocalTime; + +public class MockDetailInformationDto extends VisitationResponse.DetailInformationDto { + + public MockDetailInformationDto() { + super(); + } + + public MockDetailInformationDto( + LocalDate invitationStartDate, + LocalTime invitationStartTime, + LocalDate invitationEndDate, + LocalTime invitationEndTime, + String invitationBuildingName, + String invitationOfficeName, + String buildingRepresentativeImageUrl, + String buildingName, + String buildingAddress, + String buildingParkingCostInformation, + String buildingScale, + String placeType, + String invitationTip, + String hostName, + String hostCompanyName, + String hostContact, + String hostBusinessCardImageUrl + ) { + super( + invitationStartDate, + invitationStartTime, + invitationEndDate, + invitationEndTime, + invitationBuildingName, + invitationOfficeName, + buildingRepresentativeImageUrl, + buildingName, + buildingAddress, + buildingParkingCostInformation, + buildingScale, + placeType, + invitationTip, + hostName, + hostCompanyName, + hostContact, + hostBusinessCardImageUrl + ); + } +} diff --git a/src/test/java/com/livable/server/visitation/mock/MockInvitationDetailTimeDto.java b/src/test/java/com/livable/server/visitation/mock/MockInvitationDetailTimeDto.java new file mode 100644 index 00000000..1322280d --- /dev/null +++ b/src/test/java/com/livable/server/visitation/mock/MockInvitationDetailTimeDto.java @@ -0,0 +1,29 @@ +package com.livable.server.visitation.mock; + +import com.livable.server.invitation.dto.InvitationDetailTimeDto; + +import java.time.LocalDate; +import java.time.LocalTime; + +public class MockInvitationDetailTimeDto implements InvitationDetailTimeDto { + + @Override + public LocalDate getStartDate() { + return LocalDate.now(); + } + + @Override + public LocalDate getEndDate() { + return LocalDate.now(); + } + + @Override + public LocalTime getStartTime() { + return LocalTime.of(1, 10); + } + + @Override + public LocalTime getEndTime() { + return LocalTime.of(1, 20); + } +} diff --git a/src/test/java/com/livable/server/visitation/mock/MockParkingLog.java b/src/test/java/com/livable/server/visitation/mock/MockParkingLog.java new file mode 100644 index 00000000..6f90e3df --- /dev/null +++ b/src/test/java/com/livable/server/visitation/mock/MockParkingLog.java @@ -0,0 +1,13 @@ +package com.livable.server.visitation.mock; + +import com.livable.server.entity.ParkingLog; +import com.livable.server.entity.Visitor; + +import java.time.LocalDateTime; + +public class MockParkingLog extends ParkingLog { + + public MockParkingLog(Long id, Visitor visitor, String carNumber, LocalDateTime inTime, LocalDateTime outTime, Integer stayTime) { + super(id, visitor, carNumber, inTime, outTime, stayTime); + } +} diff --git a/src/test/java/com/livable/server/visitation/mock/MockQrPayload.java b/src/test/java/com/livable/server/visitation/mock/MockQrPayload.java new file mode 100644 index 00000000..11a7cf37 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/mock/MockQrPayload.java @@ -0,0 +1,26 @@ +package com.livable.server.visitation.mock; + +import com.livable.server.visitation.domain.QrPayload; + +import java.time.LocalDateTime; + +public class MockQrPayload extends QrPayload { + + private final LocalDateTime startDate; + private final LocalDateTime endDate; + + public MockQrPayload(LocalDateTime startDate, LocalDateTime endDate) { + this.startDate = startDate; + this.endDate = endDate; + } + + @Override + public LocalDateTime getStartDate() { + return startDate; + } + + @Override + public LocalDateTime getEndDate() { + return endDate; + } +} diff --git a/src/test/java/com/livable/server/visitation/mock/MockRegisterParkingDto.java b/src/test/java/com/livable/server/visitation/mock/MockRegisterParkingDto.java new file mode 100644 index 00000000..fb70b815 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/mock/MockRegisterParkingDto.java @@ -0,0 +1,17 @@ +package com.livable.server.visitation.mock; + +import com.livable.server.visitation.dto.VisitationRequest; + +public class MockRegisterParkingDto extends VisitationRequest.RegisterParkingDto { + + private final String carNumber; + + public MockRegisterParkingDto(String carNumber) { + this.carNumber = carNumber; + } + + @Override + public String getCarNumber() { + return carNumber; + } +} diff --git a/src/test/java/com/livable/server/visitation/mock/MockValidateQrCodeDto.java b/src/test/java/com/livable/server/visitation/mock/MockValidateQrCodeDto.java new file mode 100644 index 00000000..ea0084c9 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/mock/MockValidateQrCodeDto.java @@ -0,0 +1,19 @@ +package com.livable.server.visitation.mock; + +import com.livable.server.visitation.dto.VisitationRequest; + +public class MockValidateQrCodeDto extends VisitationRequest.ValidateQrCodeDto { + private String qr; + + public MockValidateQrCodeDto() { + } + + public MockValidateQrCodeDto(String qr) { + this.qr = qr; + } + + @Override + public String getQr() { + return qr; + } +} diff --git a/src/test/java/com/livable/server/visitation/repository/ParkingLogRepositoryTest.java b/src/test/java/com/livable/server/visitation/repository/ParkingLogRepositoryTest.java new file mode 100644 index 00000000..937023dc --- /dev/null +++ b/src/test/java/com/livable/server/visitation/repository/ParkingLogRepositoryTest.java @@ -0,0 +1,116 @@ +package com.livable.server.visitation.repository; + +import com.livable.server.core.config.QueryDslConfig; +import com.livable.server.entity.*; +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.context.annotation.Import; + +import javax.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DataJpaTest +@Import(QueryDslConfig.class) +class ParkingLogRepositoryTest { + + public static final LocalDate START_DATE = LocalDate.now(); + public static final LocalTime START_TIME = LocalTime.of(1, 10); + public static final LocalTime END_TIME = LocalTime.of(1, 20); + public static final LocalDate END_DATE = LocalDate.now(); + + @Autowired + ParkingLogRepository parkingLogRepository; + + @Autowired + VisitorRepository visitorRepository; + + @Autowired + EntityManager entityManager; + + @BeforeEach + void dataInit() { + Building building = Building.builder() + .name("63빌딩") + .scale("지하 3층, 지상 63층") + .representativeImageUrl("./thumbnailImage.jpg") + .endTime(LocalTime.of(10, 30)) + .startTime(LocalTime.of(18, 30)) + .parkingCostInformation("10분당 1000원") + .longitude("10.10.10.10") + .latitude("123.123.123") + .hasCafeteria(false) + .address("서울시 강남구 서초대로 61길 7, 392") + .subwayStation("석촌역") + .build(); + + entityManager.persist(building); + + Company company = Company.builder() + .name("패스트캠퍼스") + .building(building) + .build(); + + entityManager.persist(company); + + Member member = Member.builder() + .company(company) + .contact("01012345678") + .name("김훈섭") + .email("test@naver.com") + .employeeNumber("9q0mavfdmmpoaskp") + .profileImageUrl("./profileImageUrl") + .password("1234") + .role(Role.USER) + .build(); + + entityManager.persist(member); + + Invitation invitation = Invitation.builder() + .member(member) + .endDate(END_DATE) + .startDate(START_DATE) + .startTime(START_TIME) + .endTime(END_TIME) + .description("알아서 와") + .purpose("INTERVIEW") + .officeName("패스트캠퍼스 사무실") + .build(); + + entityManager.persist(invitation); + + Visitor visitor = Visitor.builder() + .invitation(invitation) + .name("최태윤") + .contact("01034567811") + .build(); + + entityManager.persist(visitor); + + ParkingLog parkingLog = ParkingLog.builder() + .visitor(visitor) + .carNumber("12가1234") + .build(); + + entityManager.persist(parkingLog); + } + + @DisplayName("ParkingLogRepository.findParkingLogByVisitorId 쿼리 테스트") + @Test + void test() { + Visitor visitor = visitorRepository.findAll().get(0); + ParkingLog parkingLog = parkingLogRepository.findParkingLogByVisitorId(visitor.getId()).get(); + + assertAll( + () -> assertThat(parkingLog.getId()).isEqualTo(1L), + () -> assertThat(parkingLog.getVisitor()).isEqualTo(visitor), + () -> assertThat(parkingLog.getCarNumber()).isEqualTo("12가1234") + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/repository/VisitorRepositoryTest.java b/src/test/java/com/livable/server/visitation/repository/VisitorRepositoryTest.java new file mode 100644 index 00000000..0dedd6d3 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/repository/VisitorRepositoryTest.java @@ -0,0 +1,151 @@ +package com.livable.server.visitation.repository; + +import com.livable.server.core.config.QueryDslConfig; +import com.livable.server.entity.*; +import com.livable.server.visitation.domain.PlaceType; +import com.livable.server.visitation.dto.VisitationResponse; +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.context.annotation.Import; + +import javax.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DataJpaTest +@Import(QueryDslConfig.class) +class VisitorRepositoryTest { + + public static final LocalDate START_DATE = LocalDate.now(); + public static final LocalTime START_TIME = LocalTime.of(1, 10); + public static final LocalTime END_TIME = LocalTime.of(1, 20); + public static final LocalDate END_DATE = LocalDate.now(); + + @Autowired + VisitorRepository visitorRepository; + + @Autowired + EntityManager entityManager; + + @BeforeEach + void dataInit() { + Building building = Building.builder() + .name("63빌딩") + .scale("지하 3층, 지상 63층") + .representativeImageUrl("./thumbnailImage.jpg") + .endTime(LocalTime.of(10, 30)) + .startTime(LocalTime.of(18, 30)) + .parkingCostInformation("10분당 1000원") + .longitude("10.10.10.10") + .latitude("123.123.123") + .hasCafeteria(false) + .address("서울시 강남구 서초대로 61길 7, 392") + .subwayStation("석촌역") + .build(); + + entityManager.persist(building); + + Company company = Company.builder() + .name("패스트캠퍼스") + .building(building) + .build(); + + entityManager.persist(company); + + Member member = Member.builder() + .company(company) + .contact("01012345678") + .name("김훈섭") + .email("test@naver.com") + .employeeNumber("9q0mavfdmmpoaskp") + .profileImageUrl("./profileImageUrl") + .password("1234") + .role(Role.USER) + .build(); + + entityManager.persist(member); + + Invitation invitation = Invitation.builder() + .member(member) + .endDate(END_DATE) + .startDate(START_DATE) + .startTime(START_TIME) + .endTime(END_TIME) + .description("알아서 와") + .purpose("INTERVIEW") + .officeName("패스트캠퍼스 사무실") + .build(); + + entityManager.persist(invitation); + + Visitor visitor = Visitor.builder() + .invitation(invitation) + .name("최태윤") + .contact("01034567811") + .build(); + + entityManager.persist(visitor); + } + + @DisplayName("VisitorRepository.findVisitationDetailInformationById 쿼리 성공 테스트") + @Test + void findVisitationDetailInformationByIdSuccessTest() { + Visitor visitor = visitorRepository.findAll().get(0); + Invitation invitation = visitor.getInvitation(); + Member member = invitation.getMember(); + Company company = member.getCompany(); + Building building = company.getBuilding(); + + VisitationResponse.DetailInformationDto detailInformationDto = + visitorRepository.findVisitationDetailInformationById(visitor.getId()).get(); + + assertAll( + () -> assertThat(detailInformationDto.getInvitationStartDate()).isEqualTo(visitor.getInvitation().getStartDate()), + () -> assertThat(detailInformationDto.getInvitationEndDate()).isEqualTo(visitor.getInvitation().getEndDate()), + () -> assertThat(detailInformationDto.getInvitationStartTime()).isEqualTo(visitor.getInvitation().getStartTime()), + () -> assertThat(detailInformationDto.getInvitationEndTime()).isEqualTo(visitor.getInvitation().getEndTime()), + () -> assertThat(detailInformationDto.getHostName()).isEqualTo(member.getName()), + () -> assertThat(detailInformationDto.getHostContact()).isEqualTo(member.getContact()), + () -> assertThat(detailInformationDto.getBuildingAddress()).isEqualTo(building.getAddress()), + () -> assertThat(detailInformationDto.getPlaceType()).isEqualTo(PlaceType.COMPANY), + () -> assertThat(detailInformationDto.getBuildingName()).isEqualTo(building.getName()), + () -> assertThat(detailInformationDto.getBuildingParkingCostInformation()).isEqualTo(building.getParkingCostInformation()), + () -> assertThat(detailInformationDto.getBuildingRepresentativeImageUrl()).isEqualTo(building.getRepresentativeImageUrl()) + ); + } + + @DisplayName("VisitorRepository.findBuildingIdById 쿼리 테스트_1") + @Test + void findBuildingIdByIdSuccessTest_1() { + Visitor visitor = visitorRepository.findAll().get(0); + Invitation invitation = visitor.getInvitation(); + Member member = invitation.getMember(); + Company company = member.getCompany(); + Building building = company.getBuilding(); + + Long buildingIdById = visitorRepository.findBuildingIdById(visitor.getId()).get(); + + assertThat(buildingIdById).isEqualTo(building.getId()); + } + + @DisplayName("VisitorRepository.findBuildingIdById 쿼리 테스트_2") + @Test + void findBuildingIdByIdSuccessTest_2() { + Visitor visitor = visitorRepository.findAll().get(0); + Invitation invitation = visitor.getInvitation(); + Member member = invitation.getMember(); + Company company = member.getCompany(); + Building building = company.getBuilding(); + + Optional buildingIdById = visitorRepository.findBuildingIdById(visitor.getId() + 1); + + assertThat(buildingIdById).isEqualTo(Optional.empty()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/service/ParkingLogServiceTest.java b/src/test/java/com/livable/server/visitation/service/ParkingLogServiceTest.java new file mode 100644 index 00000000..c7d21546 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/service/ParkingLogServiceTest.java @@ -0,0 +1,100 @@ +package com.livable.server.visitation.service; + +import com.livable.server.entity.ParkingLog; +import com.livable.server.entity.Visitor; +import com.livable.server.visitation.mock.MockParkingLog; +import com.livable.server.visitation.repository.ParkingLogRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class ParkingLogServiceTest { + + @InjectMocks + ParkingLogService parkingLogService; + + @Mock + ParkingLogRepository parkingLogRepository; + + @DisplayName("ParkingLogService.findParkingLogByVisitorId 성공 테스트") + @Test + void findParkingLogByVisitorIdSuccessTest() { + + // Given + ParkingLog parkingLog = ParkingLog.builder() + .build(); + + Optional expectedOptionalParkingLog = Optional.of(parkingLog); + + given(parkingLogRepository.findParkingLogByVisitorId(anyLong())).willReturn(Optional.of(parkingLog)); + + // When + Optional optionalParkingLog = parkingLogService.findParkingLogByVisitorId(1L); + + // Then + assertThat(optionalParkingLog).isEqualTo(expectedOptionalParkingLog); + then(parkingLogRepository).should(times(1)).findParkingLogByVisitorId(anyLong()); + } + + @DisplayName("ParkingLogService.registerParkingLog 성공 테스트") + @Test + void registerParkingLogSuccessTest() { + + String carNumber = "testCarNumber"; + + MockedStatic parkingLogMockedStatic = mockStatic(ParkingLog.class); + Visitor visitor = Visitor.builder() + .build(); + + ParkingLog parkingLog = + new MockParkingLog( + null, visitor, carNumber, null, null, null + ); + + // Given + given(ParkingLog.create(any(Visitor.class), anyString())).willReturn(parkingLog); + given(parkingLogRepository.save(any())).willReturn(parkingLog); + + // When + parkingLogService.registerParkingLog(visitor, carNumber); + + // Then + then(parkingLogRepository).should(times(1)).save(any()); + parkingLogMockedStatic.verify( + () -> ParkingLog.create(any(Visitor.class), anyString()), + times(1) + ); + + parkingLogMockedStatic.close(); + } + + @DisplayName("ParkingLogService.findCarNumberByVisitorId 성공 테스트") + @Test + void findCarNumberByVisitorIdSuccessTest() { + + String carNumber = "testCarNumber"; + Optional optionalCarNumber = Optional.of(carNumber); + + // Given + given(parkingLogRepository.findCarNumberByVisitorId(anyLong())).willReturn(optionalCarNumber); + + // When + Optional result = parkingLogService.findCarNumberByVisitorId(1L); + + // Then + then(parkingLogRepository).should(times(1)).findCarNumberByVisitorId(anyLong()); + assertThat(optionalCarNumber).isEqualTo(result); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/service/VisitationFacadeServiceTest.java b/src/test/java/com/livable/server/visitation/service/VisitationFacadeServiceTest.java new file mode 100644 index 00000000..cae1b884 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/service/VisitationFacadeServiceTest.java @@ -0,0 +1,162 @@ +package com.livable.server.visitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.ParkingLog; +import com.livable.server.entity.Visitor; +import com.livable.server.invitation.service.InvitationService; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.mock.MockDetailInformationDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class VisitationFacadeServiceTest { + + private final static String QR_CODE = "QR_CODE"; + + @InjectMocks + VisitationFacadeService visitationFacadeService; + + @Mock + VisitationService visitationService; + + @Mock + InvitationService invitationService; + + @Mock + VisitorService visitorService; + + @Mock + ParkingLogService parkingLogService; + + @DisplayName("VisitationFacadeService.findVisitationDetailInformation 성공 테스트") + @Test + void findVisitationDetailInformationSuccessTest() { + // Given + MockDetailInformationDto mockDetailInformationDto = new MockDetailInformationDto(); + given(visitorService.findVisitationDetailInformation(anyLong())).willReturn(mockDetailInformationDto); + + // When + VisitationResponse.DetailInformationDto detailInformationDto = + visitationFacadeService.findVisitationDetailInformation(1L); + + // Then + assertThat(detailInformationDto).isEqualTo(mockDetailInformationDto); + then(visitorService).should(times(1)).findVisitationDetailInformation(anyLong()); + } + + @DisplayName("VisitationFacadeService.createQrCode 성공 테스트") + @Test + void createQrCodeSuccessTest() { + // Given + VisitationResponse.InvitationTimeDto invitationTimeDto = VisitationResponse.InvitationTimeDto.builder() + .startDate(LocalDate.now()) + .endDate(LocalDate.now()) + .startTime(LocalTime.now()) + .endTime(LocalTime.now()) + .build(); + VisitationResponse.Base64QrCode expected = VisitationResponse.Base64QrCode.of(QR_CODE); + + given(invitationService.findInvitationTime(anyLong())).willReturn(invitationTimeDto); + given(visitationService.createQrCode(any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(QR_CODE); + + + // When + VisitationResponse.Base64QrCode qrCode = visitationFacadeService.createQrCode(1L); + + // Then + assertThat(qrCode).usingRecursiveComparison().isEqualTo(expected); + then(invitationService).should(times(1)).findInvitationTime(anyLong()); + then(visitationService).should(times(1)).createQrCode(any( + LocalDateTime.class), any(LocalDateTime.class) + ); + } + + @DisplayName("VisitationFacadeService.validateQrCode 성공 테스트") + @Test + void validateQrCodeSuccessTest() { + + // Given + willDoNothing().given(visitationService).validateQrCode(anyString()); + willDoNothing().given(visitorService).doEntrance(anyLong()); + + // When + visitationFacadeService.validateQrCode(QR_CODE, 1L); + + // Then + then(visitationService).should(times(1)).validateQrCode(anyString()); + } + + @DisplayName("VisitationFacadeService.registerParking 성공 테스트") + @Test + void registerParkingSuccessTest() { + // Given + Visitor visitor = Visitor.builder() + .id(1L) + .build(); + given(visitorService.findById(anyLong())).willReturn(visitor); + given(parkingLogService.findParkingLogByVisitorId(any())).willReturn(Optional.empty()); + willDoNothing().given(parkingLogService).registerParkingLog(any(), anyString()); + + // When + visitationFacadeService.registerParking(visitor.getId(), "12가1234"); + + // Then + then(visitorService).should(times(1)).findById(anyLong()); + then(parkingLogService).should(times(1)).findParkingLogByVisitorId(any()); + then(parkingLogService).should(times(1)).registerParkingLog(any(), anyString()); + } + + @DisplayName("VisitationFacadeService.registerParking 실패 테스트") + @Test + void registerParkingFailTest() { + // Given + ParkingLog parkingLog = ParkingLog.builder() + .build(); + given(parkingLogService.findParkingLogByVisitorId(any())).willReturn(Optional.of(parkingLog)); + + // When + GlobalRuntimeException globalRuntimeException = assertThrows( + GlobalRuntimeException.class, + () -> visitationFacadeService.registerParking(any(), "12가1234") + ); + + // Then + then(parkingLogService).should(times(1)).findParkingLogByVisitorId(any()); + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.ALREADY_REGISTER_PARKING); + } + + @DisplayName("VisitationFacadeService.findCarNumber 성공 테스트") + @Test + void findCarNumberSuccessTest() { + // Given + String carNumber = "12가1234"; + VisitationResponse.CarNumber dto = VisitationResponse.CarNumber.of(1L, carNumber); + given(parkingLogService.findCarNumberByVisitorId(any())).willReturn(Optional.of("12가1234")); + + // When + + VisitationResponse.CarNumber expectedCarNumber = visitationFacadeService.findCarNumber(1L); + + // Then + then(parkingLogService).should(times(1)).findCarNumberByVisitorId(any()); + assertThat(dto).usingRecursiveComparison().isEqualTo(expectedCarNumber); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/service/VisitationServiceTest.java b/src/test/java/com/livable/server/visitation/service/VisitationServiceTest.java new file mode 100644 index 00000000..5ad11779 --- /dev/null +++ b/src/test/java/com/livable/server/visitation/service/VisitationServiceTest.java @@ -0,0 +1,59 @@ +package com.livable.server.visitation.service; + +import com.livable.server.visitation.domain.QrCodeManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class VisitationServiceTest { + + @InjectMocks + VisitationService visitationService; + + @Mock + QrCodeManager qrCodeManager; + + @DisplayName("VisitationService.createQrCode 성공 테스트") + @Test + void createQrCodeSuccessTest() { + // Given + String qrCode = "qrCode"; + LocalDateTime startDate = LocalDateTime.of(2023, 9, 18, 1, 10); + LocalDateTime endDate = LocalDateTime.of(2023, 9, 18, 1, 11); + + given(qrCodeManager.createQrCode(any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(qrCode); + +// When + String resultQrCode = visitationService.createQrCode(startDate, endDate); + + // Then + assertThat(qrCode).isEqualTo(resultQrCode); + then(qrCodeManager).should(times(1)).createQrCode(any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @DisplayName("VisitationService.validateQrCode 성공 테스트") + @Test + void validateQrCodeSuccessTest() { + // Given + String qrCode = "qrCode"; + + willDoNothing().given(qrCodeManager).validateQrCode(anyString()); + + // When + visitationService.validateQrCode(anyString()); + + // Then + then(qrCodeManager).should(times(1)).validateQrCode(anyString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/livable/server/visitation/service/VisitorServiceTest.java b/src/test/java/com/livable/server/visitation/service/VisitorServiceTest.java new file mode 100644 index 00000000..182c73da --- /dev/null +++ b/src/test/java/com/livable/server/visitation/service/VisitorServiceTest.java @@ -0,0 +1,144 @@ +package com.livable.server.visitation.service; + +import com.livable.server.core.exception.GlobalRuntimeException; +import com.livable.server.entity.Invitation; +import com.livable.server.entity.Visitor; +import com.livable.server.visitation.domain.VisitationErrorCode; +import com.livable.server.visitation.dto.VisitationResponse; +import com.livable.server.visitation.mock.MockDetailInformationDto; +import com.livable.server.visitation.repository.VisitorRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class VisitorServiceTest { + + @InjectMocks + VisitorService visitorService; + + @Mock + VisitorRepository visitorRepository; + + @DisplayName("VisitorService.findInvitationId 성공 테스트") + @Test + void findInvitationIdSuccessTest() { + // Given + Invitation invitation = Invitation.builder() + .id(1L) + .build(); + + Visitor visitor = Visitor.builder() + .name("태윤초이") + .invitation(invitation) + .build(); + + given(visitorRepository.findById(anyLong())).willReturn(Optional.of(visitor)); + + // When + Long invitationId = visitorService.findInvitationId(1L); + + // Then + assertThat(invitationId).isEqualTo(1L); + then(visitorRepository).should(times(1)).findById(anyLong()); + } + + @DisplayName("VisitorService.findInvitationId 실패 테스트") + @Test + void findInvitationIdFailTest() { + // Given + Invitation invitation = Invitation.builder() + .id(1L) + .build(); + + Visitor visitor = Visitor.builder() + .name("태윤초이") + .invitation(invitation) + .build(); + + given(visitorRepository.findById(anyLong())).willReturn(Optional.empty()); + + // When + GlobalRuntimeException globalRuntimeException = assertThrows(GlobalRuntimeException.class, () -> visitorService.findInvitationId(1L)); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.NOT_FOUND); + then(visitorRepository).should(times(1)).findById(anyLong()); + } + + @DisplayName("VisitorService.findVisitationDetailInformation 성공 테스트") + @Test + void findVisitationDetailInformationByIdSuccessTest() { + // Given + MockDetailInformationDto mockDetailInformationDto = new MockDetailInformationDto(); + given(visitorRepository.findVisitationDetailInformationById(anyLong())).willReturn(Optional.of(mockDetailInformationDto)); + + // When + VisitationResponse.DetailInformationDto detailInformationDto = visitorService.findVisitationDetailInformation(1L); + + // Then + assertThat(detailInformationDto).isEqualTo(mockDetailInformationDto); + then(visitorRepository).should(times(1)).findVisitationDetailInformationById(anyLong()); + } + + @DisplayName("VisitorService.findVisitationDetailInformation 실패 테스트") + @Test + void findVisitationDetailInformationByIdFailTest() { + // Given + given(visitorRepository.findVisitationDetailInformationById(anyLong())).willReturn(Optional.empty()); + + // When + GlobalRuntimeException globalRuntimeException = + assertThrows( + GlobalRuntimeException.class, () -> visitorService.findVisitationDetailInformation(1L) + ); + + // Then + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.NOT_FOUND); + then(visitorRepository).should(times(1)).findVisitationDetailInformationById(anyLong()); + } + + @DisplayName("VisitorService.doEntrance 성공 테스트") + @Test + void doEntranceSuccessTest() { + // given + Visitor visitor = spy(Visitor.class); + given(visitorRepository.findById(anyLong())).willReturn(Optional.of(visitor)); + willDoNothing().given(visitor).entrance(); + + // when + visitorService.doEntrance(1L); + + // then + then(visitorRepository).should(times(1)).findById(anyLong()); + then(visitor).should(times(1)).entrance(); + } + + @DisplayName("VisitorService.doEntrance 실패 테스트") + @Test + void doEntranceFailTest() { + // given + given(visitorRepository.findById(anyLong())).willReturn(Optional.empty()); + + // when + GlobalRuntimeException globalRuntimeException = + assertThrows(GlobalRuntimeException.class, () -> visitorService.doEntrance(1L)); + + // then + then(visitorRepository).should(times(1)).findById(anyLong()); + assertThat(globalRuntimeException.getErrorCode()).isEqualTo(VisitationErrorCode.NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..39b025d4 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,15 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + show_sql: true + format_sql: true + mvc: + path match: + matching-strategy: ant_path_matcher + +logging: + pattern: + dateformat: yyyy-MM-dd HH:mm:ss.SSSz,Asia/Seoul \ No newline at end of file