diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e413ffe6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +# ✅ 작업 내용 + +- 작업 내용 1 +- 작업 내용 2 +- 작업 내용 3 + +# 📸 스크린샷 + +# 🙈 참고 사항 diff --git a/.github/workflows/be-cd-dev.yml b/.github/workflows/be-cd-dev.yml new file mode 100644 index 00000000..20ea7396 --- /dev/null +++ b/.github/workflows/be-cd-dev.yml @@ -0,0 +1,67 @@ +name: BE CD DEV + +on: + push: + branches: [ develop/be ] + +jobs: + build: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Make keystore file + run: echo "${{secrets.SSL_KEYSTORE}}" | base64 --decode > ./src/main/resources/keystore.p12 + + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{runner.os}}-gradle-${{hashFiles('**/*.gradle*', '**/gradle-wrapper.properties')}} + restore-keys: | + ${{runner.os}}-gradle- + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Sign in Dockerhub + uses: docker/login-action@v1 + with: + username: ${{secrets.DOCKER_USERNAME}} + password: ${{secrets.DOCKER_PASSWORD}} + + - name: Build the Docker image + run: docker build -f ./Dockerfile --platform linux/arm64 --no-cache -t touroot/touroot-api . + + - name: Push the Docker Image to Dockerhub + run: docker push touroot/touroot-api + + deploy: + needs: build + runs-on: self-hosted + + steps: + - name: Docker Image pull + run: sudo docker pull touroot/touroot-api + + - name: Docker Compose up + run: sudo docker compose -f ~/docker/touroot-docker.yml up -d diff --git a/.github/workflows/be-ci.yml b/.github/workflows/be-ci.yml new file mode 100644 index 00000000..106307c3 --- /dev/null +++ b/.github/workflows/be-ci.yml @@ -0,0 +1,57 @@ +name: BE CI + +on: + pull_request: + branches: [ production/be, develop/be ] + +jobs: + build: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Make keystore file + run: echo "${{secrets.SSL_KEYSTORE}}" | base64 --decode > ./src/main/resources/keystore.p12 + + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{runner.os}}-gradle-${{hashFiles('**/*.gradle*', '**/gradle-wrapper.properties')}} + restore-keys: | + ${{runner.os}}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Test with Gradle + run: ./gradlew build + + - name: Publish Unit Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: ${{ always() }} + with: + files: ${{ github.workspace }}/backend/build/test-results/**/*.xml + seconds_between_github_reads: 1.0 + seconds_between_github_writes: 3.0 + secondary_rate_limit_wait_seconds: 90.0 + + - name: When test fail, comment on that code + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: ${{ github.workspace }}/backend/build/test-results/**/*.xml + token: ${{ github.token }} diff --git a/.github/workflows/be-prod-cd.yml b/.github/workflows/be-prod-cd.yml new file mode 100644 index 00000000..4b986d7b --- /dev/null +++ b/.github/workflows/be-prod-cd.yml @@ -0,0 +1,67 @@ +name: BE CD PROD + +on: + push: + branches: [ production/be ] + +jobs: + build: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: ./backend + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Make keystore file + run: echo "${{secrets.PROD_SSL_KEYSTORE}}" | base64 --decode > ./src/main/resources/keystore.p12 + + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{runner.os}}-gradle-${{hashFiles('**/*.gradle*', '**/gradle-wrapper.properties')}} + restore-keys: | + ${{runner.os}}-gradle- + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Sign in Dockerhub + uses: docker/login-action@v1 + with: + username: ${{secrets.DOCKER_USERNAME}} + password: ${{secrets.DOCKER_PASSWORD}} + + - name: Build the Docker image + run: docker build -f ./Dockerfile --platform linux/arm64 --no-cache -t touroot/touroot-api:prod . + + - name: Push the Docker Image to Dockerhub + run: docker push touroot/touroot-api:prod + + deploy: + needs: build + runs-on: [self-hosted, prod] + + steps: + - name: Docker Image pull + run: sudo docker pull touroot/touroot-api:prod + + - name: Docker Compose up + run: sudo docker compose -f ~/docker/api-docker.yml up -d diff --git a/backend/.gitignore b/backend/.gitignore index 3409fd4e..69da556b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -3,3 +3,5 @@ build **/.DS_Store +/log +/src/main/resources/keystore.p12 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..2e05cbe0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-oracle +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "/app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle index 6efc0c7f..5a45039d 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,40 +1,66 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.1' - id 'io.spring.dependency-management' version '1.1.5' + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' } -group = 'woowacourse' +group = 'kr.touroot' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.4' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + implementation 'software.amazon.awssdk:s3:2.20.28' + implementation 'software.amazon.awssdk:sts:2.20.28' + implementation 'software.amazon.awssdk:auth:2.20.28' + implementation 'io.findify:s3mock_2.13:0.2.6' + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:5.5.0' + testImplementation 'io.findify:s3mock_2.13:0.2.6' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // cache + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar index e69de29b..2c352119 100644 Binary files a/backend/gradle/wrapper/gradle-wrapper.jar and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties index a4413138..09523c0e 100644 --- a/backend/gradle/wrapper/gradle-wrapper.properties +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/backend/gradlew b/backend/gradlew old mode 100644 new mode 100755 index b740cf13..f5feea6d --- a/backend/gradlew +++ b/backend/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/backend/gradlew.bat b/backend/gradlew.bat index e69de29b..9b42019c 100644 --- a/backend/gradlew.bat +++ b/backend/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/src/main/java/kr/touroot/TourootApplication.java b/backend/src/main/java/kr/touroot/TourootApplication.java new file mode 100644 index 00000000..8cc4e6ee --- /dev/null +++ b/backend/src/main/java/kr/touroot/TourootApplication.java @@ -0,0 +1,16 @@ +package kr.touroot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableCaching +@EnableJpaAuditing +@SpringBootApplication +public class TourootApplication { + + public static void main(String[] args) { + SpringApplication.run(TourootApplication.class, args); + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/controller/LoginController.java b/backend/src/main/java/kr/touroot/authentication/controller/LoginController.java new file mode 100644 index 00000000..2d7caec1 --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/controller/LoginController.java @@ -0,0 +1,86 @@ +package kr.touroot.authentication.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import kr.touroot.authentication.dto.request.LoginRequest; +import kr.touroot.authentication.dto.request.TokenReissueRequest; +import kr.touroot.authentication.dto.response.LoginResponse; +import kr.touroot.authentication.service.LoginService; +import kr.touroot.global.exception.dto.ExceptionResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "로그인") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/login") +public class LoginController { + + private final LoginService loginService; + + @Operation(summary = "카카오 소셜 로그인") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 파라미터에 올바르지 않은 인가코드값이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PostMapping("/oauth/kakao") + public ResponseEntity kakaoLogin( + @RequestParam(name = "code") String authorizationCode, + @RequestParam(name = "redirectUri") String encodedRedirectUri + ) { + return ResponseEntity.ok() + .body(loginService.login(authorizationCode, encodedRedirectUri)); + } + + @Operation(summary = "투룻 서비스 자체 로그인") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 이메일 또는 비밀번호가 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PostMapping + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + return ResponseEntity.ok() + .body(loginService.login(request)); + } + + @Operation(summary = "리프레스 토큰으로 재로그인") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 refresh token이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PostMapping("/reissue-token") + public ResponseEntity reissueToken(@Valid @RequestBody TokenReissueRequest request) { + return ResponseEntity.ok(loginService.reissueToken(request)); + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/request/LoginRequest.java b/backend/src/main/java/kr/touroot/authentication/dto/request/LoginRequest.java new file mode 100644 index 00000000..2cbde17b --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/request/LoginRequest.java @@ -0,0 +1,16 @@ +package kr.touroot.authentication.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @Schema(description = "사용자 이메일", example = "email@gmail.com") + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email + String email, + @Schema(description = "사용자 비밀번호", example = "@testpassword1234") + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + String password +) { +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/request/TokenReissueRequest.java b/backend/src/main/java/kr/touroot/authentication/dto/request/TokenReissueRequest.java new file mode 100644 index 00000000..2fa40beb --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/request/TokenReissueRequest.java @@ -0,0 +1,10 @@ +package kr.touroot.authentication.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TokenReissueRequest( + @Schema(description = "로그인 시 발급받은 리프레시 토큰") + @NotBlank(message = "리프레시 토큰은 비어있을 수 없습니다.") String refreshToken +) { +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/response/LoginResponse.java b/backend/src/main/java/kr/touroot/authentication/dto/response/LoginResponse.java new file mode 100644 index 00000000..0a4013a8 --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/response/LoginResponse.java @@ -0,0 +1,26 @@ +package kr.touroot.authentication.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.touroot.member.domain.Member; +import lombok.Builder; + +@Builder +public record LoginResponse( + @Schema(description = "로그인한 유저의 PK", example = "1") Long memberId, + @Schema(description = "로그인된 유저의 닉네임", example = "리비") String nickname, + @Schema(description = "로그인된 유저의 프로필 이미지 경로", example = "https://dev.touroot.kr/profile-image-ex.png") + String profileImageUrl, + @Schema(description = "투룻 서비스 인가용 accessToken", example = "accessTokenValue") String accessToken, + @Schema(description = "투룻 서비스 refreshToken", example = "refreshTokenValue") String refreshToken +) { + + public static LoginResponse of(Member member, TokenResponse tokenResponse) { + return LoginResponse.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .profileImageUrl(member.getProfileImageUrl()) + .accessToken(tokenResponse.accessToken()) + .refreshToken(tokenResponse.refreshToken()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/response/OauthUserInformationResponse.java b/backend/src/main/java/kr/touroot/authentication/dto/response/OauthUserInformationResponse.java new file mode 100644 index 00000000..e693f2ac --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/response/OauthUserInformationResponse.java @@ -0,0 +1,27 @@ +package kr.touroot.authentication.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.touroot.authentication.dto.response.kakao.KakaoAccount; +import kr.touroot.member.domain.LoginType; +import kr.touroot.member.domain.Member; + +public record OauthUserInformationResponse( + @JsonProperty("id") + Long socialLoginId, + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { + + public Member toMember() { + return new Member(socialLoginId, nickname(), profileImage(), LoginType.KAKAO); + } + + public String nickname() { + return kakaoAccount.kakaoProfile().nickname(); + } + + public String profileImage() { + return kakaoAccount.kakaoProfile().image(); + } + +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/response/TokenResponse.java b/backend/src/main/java/kr/touroot/authentication/dto/response/TokenResponse.java new file mode 100644 index 00000000..beb98b14 --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/response/TokenResponse.java @@ -0,0 +1,4 @@ +package kr.touroot.authentication.dto.response; + +public record TokenResponse(String accessToken, String refreshToken) { +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoAccessTokenResponse.java b/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoAccessTokenResponse.java new file mode 100644 index 00000000..a1277ddc --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoAccessTokenResponse.java @@ -0,0 +1,19 @@ +package kr.touroot.authentication.dto.response.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoAccessTokenResponse( + @JsonProperty("token_type") + String tokenType, + @JsonProperty("access_token") + String accessToken, + @JsonProperty("expires_in") + Integer expiresIn, + @JsonProperty("refresh_token") + String refreshToken, + @JsonProperty("refresh_token_expires_in") + Integer refreshTokenExpiresIn, + @JsonProperty("scope") + String scope +) { +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoAccount.java b/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoAccount.java new file mode 100644 index 00000000..b099c68a --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoAccount.java @@ -0,0 +1,6 @@ +package kr.touroot.authentication.dto.response.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoAccount(@JsonProperty("profile") KakaoProfile kakaoProfile) { +} diff --git a/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoProfile.java b/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoProfile.java new file mode 100644 index 00000000..9fc3a7bc --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/dto/response/kakao/KakaoProfile.java @@ -0,0 +1,9 @@ +package kr.touroot.authentication.dto.response.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoProfile( + @JsonProperty("nickname") String nickname, + @JsonProperty("profile_image_url") String image +) { +} diff --git a/backend/src/main/java/kr/touroot/authentication/infrastructure/JwtTokenProvider.java b/backend/src/main/java/kr/touroot/authentication/infrastructure/JwtTokenProvider.java new file mode 100644 index 00000000..ab084857 --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/infrastructure/JwtTokenProvider.java @@ -0,0 +1,76 @@ +package kr.touroot.authentication.infrastructure; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import kr.touroot.authentication.dto.response.TokenResponse; +import kr.touroot.global.exception.UnauthorizedException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { + + private static final String MEMBER_ID_KEY = "id"; + + private final String accessSecretKey; + private final String refreshSecretKey; + private final long accessExpiration; + private final long refreshExpiration; + + public JwtTokenProvider( + @Value("${security.jwt.token.secret-key}") String accessSecretKey, + @Value("${security.jwt.refresh.secret-key}") String refreshSecretKey, + @Value("${security.jwt.token.expire-length}") long accessExpiration, + @Value("${security.jwt.refresh.expire-length}") long refreshExpiration + ) { + this.accessSecretKey = accessSecretKey; + this.accessExpiration = accessExpiration; + this.refreshSecretKey = refreshSecretKey; + this.refreshExpiration = refreshExpiration; + } + + public TokenResponse createToken(Long memberId) { + String accessToken = createToken(memberId, accessSecretKey, accessExpiration); + String refreshToken = createToken(memberId, refreshSecretKey, refreshExpiration); + return new TokenResponse(accessToken, refreshToken); + } + + private String createToken(Long memberId, String secretKey, long expiration) { + Date now = new Date(); + Date validity = new Date(now.getTime() + expiration); + + return Jwts.builder() + .setSubject(memberId.toString()) + .claim(MEMBER_ID_KEY, memberId) + .setExpiration(validity) + .signWith(Keys.hmacShaKeyFor(secretKey.getBytes())) + .compact(); + } + + public String decodeAccessToken(String token) { + return decode(token, accessSecretKey); + } + + public String decodeRefreshToken(String token) { + return decode(token, refreshSecretKey); + } + + private String decode(String token, String secretKey) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) + .build() + .parseClaimsJws(token) + .getBody() + .get(MEMBER_ID_KEY) + .toString(); + } catch (ExpiredJwtException exception) { + throw new UnauthorizedException("이미 만료된 토큰입니다.", exception.getCause()); + } catch (Exception exception) { + throw new UnauthorizedException("유효하지 않은 토큰입니다.", exception.getCause()); + } + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/infrastructure/KakaoOauthClient.java b/backend/src/main/java/kr/touroot/authentication/infrastructure/KakaoOauthClient.java new file mode 100644 index 00000000..a7bf6bc6 --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/infrastructure/KakaoOauthClient.java @@ -0,0 +1,99 @@ +package kr.touroot.authentication.infrastructure; + +import java.io.IOException; +import java.time.Duration; +import kr.touroot.authentication.dto.response.OauthUserInformationResponse; +import kr.touroot.authentication.dto.response.kakao.KakaoAccessTokenResponse; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ClientException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +@Slf4j +@Component +public class KakaoOauthClient { + + private final String userInformationRequestUri; + private final String accessTokenRequestUri; + private final String restApiKey; + private final RestClient restClient; + + public KakaoOauthClient( + @Value("${oauth.kakao.user-information-request-uri}") String userInformationRequestUri, + @Value("${oauth.kakao.access-token-request-uri}") String accessTokenRequestUri, + @Value("${oauth.kakao.rest-api-key}") String restApiKey + ) { + this.userInformationRequestUri = userInformationRequestUri; + this.accessTokenRequestUri = accessTokenRequestUri; + this.restApiKey = restApiKey; + this.restClient = buildRestClient(); + } + + private RestClient buildRestClient() { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(Duration.ofSeconds(1)) + .withReadTimeout(Duration.ofSeconds(3)); + + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); + } + + public OauthUserInformationResponse requestUserInformation(String authorizationCode, String redirectUri) { + KakaoAccessTokenResponse kakaoAccessTokenResponse = requestAccessToken(authorizationCode, redirectUri); + + return restClient.get() + .uri(userInformationRequestUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoAccessTokenResponse.accessToken()) + .retrieve() + .onStatus(HttpStatusCode::isError, this::handleClientError) + .onStatus(HttpStatusCode::is2xxSuccessful, this::handleSuccessLogging) + .toEntity(OauthUserInformationResponse.class) + .getBody(); + } + + private KakaoAccessTokenResponse requestAccessToken(String authorizationCode, String redirectUri) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", authorizationCode); + params.add("client_id", restApiKey); + params.add("redirect_uri", redirectUri); + params.add("grant_type", "authorization_code"); + + return restClient.post() + .uri(accessTokenRequestUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .onStatus(HttpStatusCode::isError, this::handleClientError) + .onStatus(HttpStatusCode::is2xxSuccessful, this::handleSuccessLogging) + .toEntity(KakaoAccessTokenResponse.class) + .getBody(); + } + + private void handleClientError(HttpRequest request, ClientHttpResponse response) throws IOException { + log.error("KakaoOauth:: {} {} ({})", request.getMethod(), request.getURI(), response.getStatusCode()); + + if (response.getStatusCode().is4xxClientError()) { + throw new BadRequestException("잘못된 로그인 요청입니다. 인가코드를 확인해주세요"); + } + throw new ClientException("외부 서비스의 장애로 카카오로그인을 이용할 수 없습니다"); + } + + private void handleSuccessLogging(HttpRequest request, ClientHttpResponse response) throws IOException { + log.info("KakaoOauth:: {} {} ({})", request.getMethod(), request.getURI(), response.getStatusCode()); + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/infrastructure/KakaoOauthProvider.java b/backend/src/main/java/kr/touroot/authentication/infrastructure/KakaoOauthProvider.java new file mode 100644 index 00000000..a933a3cf --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/infrastructure/KakaoOauthProvider.java @@ -0,0 +1,16 @@ +package kr.touroot.authentication.infrastructure; + +import kr.touroot.authentication.dto.response.OauthUserInformationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class KakaoOauthProvider { + + private final KakaoOauthClient kakaoOauthClient; + + public OauthUserInformationResponse getUserInformation(String authorizationCode, String redirectUri) { + return kakaoOauthClient.requestUserInformation(authorizationCode, redirectUri); + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/infrastructure/PasswordEncryptor.java b/backend/src/main/java/kr/touroot/authentication/infrastructure/PasswordEncryptor.java new file mode 100644 index 00000000..2047051f --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/infrastructure/PasswordEncryptor.java @@ -0,0 +1,25 @@ +package kr.touroot.authentication.infrastructure; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import org.springframework.stereotype.Component; + +@Component +public class PasswordEncryptor { + + public static final int HEXADECIMAL = 16; + + public String encrypt(String password) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-512"); + + byte[] message = md.digest(password.getBytes()); + BigInteger number = new BigInteger(1, message); + + return number.toString(HEXADECIMAL); + } catch (NoSuchAlgorithmException exception) { + throw new RuntimeException(); + } + } +} diff --git a/backend/src/main/java/kr/touroot/authentication/service/LoginService.java b/backend/src/main/java/kr/touroot/authentication/service/LoginService.java new file mode 100644 index 00000000..2b0c18fd --- /dev/null +++ b/backend/src/main/java/kr/touroot/authentication/service/LoginService.java @@ -0,0 +1,55 @@ +package kr.touroot.authentication.service; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import kr.touroot.authentication.dto.request.LoginRequest; +import kr.touroot.authentication.dto.request.TokenReissueRequest; +import kr.touroot.authentication.dto.response.LoginResponse; +import kr.touroot.authentication.dto.response.OauthUserInformationResponse; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.authentication.infrastructure.KakaoOauthProvider; +import kr.touroot.authentication.infrastructure.PasswordEncryptor; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import kr.touroot.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LoginService { + + private final MemberRepository memberRepository; + private final KakaoOauthProvider oauthProvider; + private final JwtTokenProvider tokenProvider; + private final PasswordEncryptor passwordEncryptor; + + public LoginResponse login(String code, String encodedRedirectUri) { + String redirectUri = URLDecoder.decode(encodedRedirectUri, StandardCharsets.UTF_8); + OauthUserInformationResponse userInformation = oauthProvider.getUserInformation(code, redirectUri); + Member member = memberRepository.findByKakaoId(userInformation.socialLoginId()) + .orElseGet(() -> signUp(userInformation)); + + return LoginResponse.of(member, tokenProvider.createToken(member.getId())); + } + + private Member signUp(OauthUserInformationResponse userInformation) { + return memberRepository.save(userInformation.toMember()); + } + + public LoginResponse login(LoginRequest request) { + String encryptPassword = passwordEncryptor.encrypt(request.password()); + Member member = memberRepository.findByEmailAndPassword(request.email(), encryptPassword) + .orElseThrow(() -> new BadRequestException("잘못된 이메일 또는 비밀번호입니다.")); + + return LoginResponse.of(member, tokenProvider.createToken(member.getId())); + } + + public LoginResponse reissueToken(TokenReissueRequest request) { + String memberId = tokenProvider.decodeRefreshToken(request.refreshToken()); + Member member = memberRepository.findById(Long.valueOf(memberId)) + .orElseThrow(() -> new BadRequestException("존재하지 않는 사용자입니다.")); + + return LoginResponse.of(member, tokenProvider.createToken(member.getId())); + } +} diff --git a/backend/src/main/java/kr/touroot/global/auth/JwtAuthFilter.java b/backend/src/main/java/kr/touroot/global/auth/JwtAuthFilter.java new file mode 100644 index 00000000..484b01ef --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/auth/JwtAuthFilter.java @@ -0,0 +1,99 @@ +package kr.touroot.global.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.global.auth.dto.HttpRequestInfo; +import kr.touroot.global.exception.dto.ExceptionResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + public static final String MEMBER_ID_ATTRIBUTE = "memberId"; + + private final ObjectMapper objectMapper; + private final JwtTokenProvider tokenProvider; + + private static final List WHITE_LIST = List.of( + new HttpRequestInfo(HttpMethod.GET, "/h2-console/**"), + new HttpRequestInfo(HttpMethod.POST, "/h2-console/**"), + new HttpRequestInfo(HttpMethod.GET, "/favicon/**"), + new HttpRequestInfo(HttpMethod.GET, "/swagger-ui/**"), + new HttpRequestInfo(HttpMethod.GET, "/swagger-resources/**"), + new HttpRequestInfo(HttpMethod.GET, "/v3/api-docs/**"), + new HttpRequestInfo(HttpMethod.GET, "/api/v1/travelogues/**"), + new HttpRequestInfo(HttpMethod.POST, "/api/v1/login/**"), + new HttpRequestInfo(HttpMethod.GET, "/api/v1/travel-plans/shared/**"), + new HttpRequestInfo(HttpMethod.POST, "/api/v1/tags/**"), + new HttpRequestInfo(HttpMethod.GET, "/api/v1/tags/**"), + new HttpRequestInfo(HttpMethod.POST, "/api/v1/members"), + new HttpRequestInfo(HttpMethod.OPTIONS, "/**") + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + if (isTokenBlank(token)) { + sendUnauthorizedResponse(response, "로그인을 해주세요."); + return; + } + + token = token.split("Bearer|bearer")[1]; + try { + String memberId = tokenProvider.decodeAccessToken(token); + request.setAttribute(MEMBER_ID_ATTRIBUTE, memberId); + filterChain.doFilter(request, response); + } catch (Exception e) { + sendUnauthorizedResponse(response, e.getMessage()); + } + } + + private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { + log.warn("UNAUTHORIZED_EXCEPTION :: message = {}", message); + + ExceptionResponse errorResponse = new ExceptionResponse(message); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + response.getWriter() + .write(objectMapper.writeValueAsString(errorResponse)); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String method = request.getMethod(); + String requestURI = request.getRequestURI(); + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + + return isInWhiteList(method, requestURI) && isTokenBlank(token); + } + + private boolean isInWhiteList(String method, String url) { + AntPathMatcher antPathMatcher = new AntPathMatcher(); + + return WHITE_LIST.stream() + .anyMatch(white -> white.method().matches(method) && antPathMatcher.match(white.urlPattern(), url)); + } + + private boolean isTokenBlank(String token) { + return token == null || token.isBlank(); + } +} diff --git a/backend/src/main/java/kr/touroot/global/auth/MemberAuthMethodArgumentResolver.java b/backend/src/main/java/kr/touroot/global/auth/MemberAuthMethodArgumentResolver.java new file mode 100644 index 00000000..4242f56d --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/auth/MemberAuthMethodArgumentResolver.java @@ -0,0 +1,38 @@ +package kr.touroot.global.auth; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.UnauthorizedException; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class MemberAuthMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(MemberAuth.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + String memberId = Objects.requireNonNull(request).getAttribute("memberId").toString(); + + if (memberId == null) { + throw new UnauthorizedException("로그인이 필요합니다."); + } + + return new MemberAuth(Long.valueOf(memberId)); + } +} diff --git a/backend/src/main/java/kr/touroot/global/auth/dto/HttpRequestInfo.java b/backend/src/main/java/kr/touroot/global/auth/dto/HttpRequestInfo.java new file mode 100644 index 00000000..e9e91c23 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/auth/dto/HttpRequestInfo.java @@ -0,0 +1,6 @@ +package kr.touroot.global.auth.dto; + +import org.springframework.http.HttpMethod; + +public record HttpRequestInfo(HttpMethod method, String urlPattern) { +} diff --git a/backend/src/main/java/kr/touroot/global/auth/dto/MemberAuth.java b/backend/src/main/java/kr/touroot/global/auth/dto/MemberAuth.java new file mode 100644 index 00000000..ab6c2c36 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/auth/dto/MemberAuth.java @@ -0,0 +1,8 @@ +package kr.touroot.global.auth.dto; + +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.constraints.NotNull; + +@Hidden +public record MemberAuth(@NotNull Long memberId) { +} diff --git a/backend/src/main/java/kr/touroot/global/config/AwsS3Config.java b/backend/src/main/java/kr/touroot/global/config/AwsS3Config.java new file mode 100644 index 00000000..1d411dd5 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/config/AwsS3Config.java @@ -0,0 +1,23 @@ +package kr.touroot.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +@Profile({"dev"}) +public class AwsS3Config { + + @Primary + @Bean(name = "s3Client", destroyMethod = "close") + public S3Client s3Client() { + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(InstanceProfileCredentialsProvider.create()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/global/config/EmbeddedS3Config.java b/backend/src/main/java/kr/touroot/global/config/EmbeddedS3Config.java new file mode 100644 index 00000000..8d38c133 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/config/EmbeddedS3Config.java @@ -0,0 +1,65 @@ +package kr.touroot.global.config; + +import io.findify.s3mock.S3Mock; +import java.net.URI; +import java.util.Random; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Profile; +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; + +@Configuration +@Profile({"default", "local"}) +public class EmbeddedS3Config { + + private static final int DYNAMIC_PORT_NUMBER_LOWER = 49152; + private static final int DYNAMIC_PORT_NUMBER_RANGE = 16384; + + private final String bucketName; + private final int port; + + public EmbeddedS3Config(@Value("${cloud.aws.s3.bucket}") String bucketName) { + this.bucketName = bucketName; + this.port = getRandomPortNumber(); + } + + private int getRandomPortNumber() { + Random random = new Random(); + return random.nextInt(DYNAMIC_PORT_NUMBER_RANGE) + DYNAMIC_PORT_NUMBER_LOWER; + } + + @Bean(name = "s3Mock", initMethod = "start", destroyMethod = "shutdown") + public S3Mock s3Mock() { + return new S3Mock.Builder() + .withPort(port) + .withInMemoryBackend() + .build(); + } + + @DependsOn({"s3Mock"}) + @Bean(name = "s3Client", destroyMethod = "close") + public S3Client s3Client() { + S3Client s3Client = S3Client.builder() + .forcePathStyle(true) + .credentialsProvider(AnonymousCredentialsProvider.create()) + .region(Region.AP_NORTHEAST_2) + .endpointOverride(URI.create("http://localhost:" + port)) + .build(); + + createLocalBucket(s3Client); + return s3Client; + } + + private void createLocalBucket(S3Client s3Client) { + CreateBucketRequest createBucketRequest = CreateBucketRequest.builder() + .bucket(bucketName) + .build(); + + s3Client.createBucket(createBucketRequest); + } +} diff --git a/backend/src/main/java/kr/touroot/global/config/JasyptConfig.java b/backend/src/main/java/kr/touroot/global/config/JasyptConfig.java new file mode 100644 index 00000000..546ba8c7 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/config/JasyptConfig.java @@ -0,0 +1,9 @@ +package kr.touroot.global.config; + +import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableEncryptableProperties +public class JasyptConfig { +} diff --git a/backend/src/main/java/kr/touroot/global/config/QueryDslConfig.java b/backend/src/main/java/kr/touroot/global/config/QueryDslConfig.java new file mode 100644 index 00000000..93be678f --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package kr.touroot.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@RequiredArgsConstructor +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/backend/src/main/java/kr/touroot/global/config/SwaggerConfig.java b/backend/src/main/java/kr/touroot/global/config/SwaggerConfig.java new file mode 100644 index 00000000..a52d3636 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/config/SwaggerConfig.java @@ -0,0 +1,39 @@ +package kr.touroot.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; + +@Profile("local|dev") +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI createOpenApi() { + return new OpenAPI() + .info(getInfo()) + .components(new Components().addSecuritySchemes("bearerAuth", getSecurityScheme())) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")); + } + + private Info getInfo() { + return new Info() + .title("touroot API") + .description("To your route, 투룻 API") + .version("0.1"); + } + + private SecurityScheme getSecurityScheme() { + return new SecurityScheme().type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name(HttpHeaders.AUTHORIZATION); + } +} diff --git a/backend/src/main/java/kr/touroot/global/config/WebConfig.java b/backend/src/main/java/kr/touroot/global/config/WebConfig.java new file mode 100644 index 00000000..18e1fd70 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/config/WebConfig.java @@ -0,0 +1,28 @@ +package kr.touroot.global.config; + +import java.util.List; +import kr.touroot.global.auth.MemberAuthMethodArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final MemberAuthMethodArgumentResolver memberAuthMethodArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(memberAuthMethodArgumentResolver); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000", "https://dev.touroot.kr") + .allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"); + } +} diff --git a/backend/src/main/java/kr/touroot/global/entity/BaseEntity.java b/backend/src/main/java/kr/touroot/global/entity/BaseEntity.java new file mode 100644 index 00000000..4e8b0238 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/entity/BaseEntity.java @@ -0,0 +1,25 @@ +package kr.touroot.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + + private LocalDateTime deletedAt; +} diff --git a/backend/src/main/java/kr/touroot/global/exception/BadRequestException.java b/backend/src/main/java/kr/touroot/global/exception/BadRequestException.java new file mode 100644 index 00000000..5309328e --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package kr.touroot.global.exception; + +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/kr/touroot/global/exception/ClientException.java b/backend/src/main/java/kr/touroot/global/exception/ClientException.java new file mode 100644 index 00000000..b5c5d920 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/ClientException.java @@ -0,0 +1,8 @@ +package kr.touroot.global.exception; + +public class ClientException extends RuntimeException { + + public ClientException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/kr/touroot/global/exception/ForbiddenException.java b/backend/src/main/java/kr/touroot/global/exception/ForbiddenException.java new file mode 100644 index 00000000..2412a780 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package kr.touroot.global.exception; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/kr/touroot/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/kr/touroot/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..3873cef1 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,91 @@ +package kr.touroot.global.exception; + +import kr.touroot.global.exception.dto.ExceptionResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(BadRequestException exception) { + log.warn("BAD_REQUEST_EXCEPTION :: message = {}", exception.getMessage()); + + ExceptionResponse data = new ExceptionResponse(exception.getMessage()); + return ResponseEntity.badRequest() + .body(data); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException exception + ) { + log.warn("METHOD_ARGUMENT_NOT_VALID_EXCEPTION :: message = {}", exception.getMessage()); + + String message = exception.getBindingResult() + .getAllErrors() + .get(0) + .getDefaultMessage(); + ExceptionResponse data = new ExceptionResponse(message); + return ResponseEntity.badRequest() + .body(data); + } + + @ExceptionHandler(ClientException.class) + public ResponseEntity handleClientException(ClientException exception) { + log.error("CLIENT_EXCEPTION :: stackTrace = ", exception); + + ExceptionResponse data = new ExceptionResponse(exception.getMessage()); + return ResponseEntity.internalServerError().body(data); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleUploadExceedException(MaxUploadSizeExceededException exception) { + log.warn("UPLOAD_SIZE_EXCEPTION :: message = {}", exception.getMessage()); + + ExceptionResponse data = new ExceptionResponse("파일 업로드 용량을 초과하였습니다."); + return ResponseEntity.badRequest().body(data); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbiddenException(ForbiddenException exception) { + log.warn("FORBIDDEN_EXCEPTION :: message = {}", exception.getMessage()); + + ExceptionResponse data = new ExceptionResponse(exception.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(data); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception exception) { + log.error("EXCEPTION :: stackTrace = ", exception); + + ExceptionResponse data = new ExceptionResponse("서버에 문제가 발생했습니다. 투룻에 문의해 주세요."); + return ResponseEntity.internalServerError() + .body(data); + } + + @ExceptionHandler(S3UploadException.class) + public ResponseEntity handleS3UploadException(S3UploadException exception) { + log.warn("S3_UPLOAD_EXCEPTION :: message = {}", exception.getMessage()); + + ExceptionResponse data = new ExceptionResponse("이미지 업로드에 실패했습니다."); + return ResponseEntity.badRequest() + .body(data); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(UnauthorizedException exception) { + log.warn("UNAUTHORIZED_EXCEPTION :: message = {}", exception.getMessage()); + + ExceptionResponse data = new ExceptionResponse(exception.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(data); + } +} diff --git a/backend/src/main/java/kr/touroot/global/exception/S3UploadException.java b/backend/src/main/java/kr/touroot/global/exception/S3UploadException.java new file mode 100644 index 00000000..42e48d5b --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/S3UploadException.java @@ -0,0 +1,8 @@ +package kr.touroot.global.exception; + +public class S3UploadException extends RuntimeException { + + public S3UploadException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/kr/touroot/global/exception/UnauthorizedException.java b/backend/src/main/java/kr/touroot/global/exception/UnauthorizedException.java new file mode 100644 index 00000000..b5c35538 --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/UnauthorizedException.java @@ -0,0 +1,12 @@ +package kr.touroot.global.exception; + +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } + + public UnauthorizedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/kr/touroot/global/exception/dto/ExceptionResponse.java b/backend/src/main/java/kr/touroot/global/exception/dto/ExceptionResponse.java new file mode 100644 index 00000000..f758da9e --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/exception/dto/ExceptionResponse.java @@ -0,0 +1,4 @@ +package kr.touroot.global.exception.dto; + +public record ExceptionResponse(String message) { +} diff --git a/backend/src/main/java/kr/touroot/global/logging/LoggingFilter.java b/backend/src/main/java/kr/touroot/global/logging/LoggingFilter.java new file mode 100644 index 00000000..255efe8d --- /dev/null +++ b/backend/src/main/java/kr/touroot/global/logging/LoggingFilter.java @@ -0,0 +1,61 @@ +package kr.touroot.global.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.touroot.global.auth.JwtAuthFilter; +import kr.touroot.global.auth.dto.HttpRequestInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +public class LoggingFilter extends OncePerRequestFilter { + + private static final List WHITE_LIST = List.of( + new HttpRequestInfo(HttpMethod.GET, "/h2-console/**"), + new HttpRequestInfo(HttpMethod.POST, "/h2-console/**"), + new HttpRequestInfo(HttpMethod.GET, "/favicon/**"), + new HttpRequestInfo(HttpMethod.GET, "/swagger-ui/**"), + new HttpRequestInfo(HttpMethod.GET, "/swagger-resources/**"), + new HttpRequestInfo(HttpMethod.GET, "/v3/api-docs/**"), + new HttpRequestInfo(HttpMethod.OPTIONS, "/**") + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + filterChain.doFilter(request, response); + + String method = request.getMethod(); + String url = request.getRequestURI(); + HttpStatus statusCode = HttpStatus.valueOf(response.getStatus()); + Object memberId = request.getAttribute(JwtAuthFilter.MEMBER_ID_ATTRIBUTE); + + if (memberId == null) { + log.info("{} {} ({})", method, url, statusCode); + return; + } + + log.info("{} {} ({}) :: userId = {}", method, url, statusCode, memberId); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + AntPathMatcher antPathMatcher = new AntPathMatcher(); + + String url = request.getRequestURI(); + String method = request.getMethod(); + + return WHITE_LIST.stream() + .anyMatch(white -> white.method().matches(method) && antPathMatcher.match(white.urlPattern(), url)); + } +} diff --git a/backend/src/main/java/kr/touroot/image/controller/ImageController.java b/backend/src/main/java/kr/touroot/image/controller/ImageController.java new file mode 100644 index 00000000..493b0800 --- /dev/null +++ b/backend/src/main/java/kr/touroot/image/controller/ImageController.java @@ -0,0 +1,46 @@ +package kr.touroot.image.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.image.service.ImageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "이미지") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/image") +public class ImageController { + + private final ImageService imageService; + + @Operation(summary = "이미지 업로드") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "jpg, jpeg, png, webp가 아닌 확장자의 파일을 업로드할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadImages(@RequestPart List files) { + List imageUrls = imageService.uploadImages(files); + return ResponseEntity.ok(imageUrls); + } +} diff --git a/backend/src/main/java/kr/touroot/image/domain/ImageFile.java b/backend/src/main/java/kr/touroot/image/domain/ImageFile.java new file mode 100644 index 00000000..8f746bfc --- /dev/null +++ b/backend/src/main/java/kr/touroot/image/domain/ImageFile.java @@ -0,0 +1,49 @@ +package kr.touroot.image.domain; + +import java.util.List; +import kr.touroot.global.exception.BadRequestException; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +public class ImageFile { + + private static final List WHITE_LIST = List.of("jpg", "jpeg", "png", "webp", "heic"); + + private final MultipartFile file; + + public ImageFile(MultipartFile file) { + validate(file); + this.file = file; + } + + private void validate(MultipartFile file) { + validateNotNull(file); + String fileName = file.getOriginalFilename(); + validateFileNameNotBlank(fileName); + validateExtension(fileName); + } + + private void validateNotNull(MultipartFile file) { + if (file == null) { + throw new BadRequestException("파일을 전달 받지 못했습니다"); + } + } + + private void validateFileNameNotBlank(String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new BadRequestException("파일 이름은 비어있을 수 없습니다"); + } + } + + public void validateExtension(String fileName) { + int extensionIndex = fileName.lastIndexOf("."); + if (extensionIndex == -1 || fileName.endsWith(".")) { + throw new BadRequestException("파일 형식이 잘못되었습니다"); + } + String extension = fileName.substring(extensionIndex + 1); + if (!WHITE_LIST.contains(extension.toLowerCase())) { + throw new BadRequestException("지원하지 않는 확장자입니다: " + extension); + } + } +} diff --git a/backend/src/main/java/kr/touroot/image/infrastructure/AwsS3Provider.java b/backend/src/main/java/kr/touroot/image/infrastructure/AwsS3Provider.java new file mode 100644 index 00000000..596bc32e --- /dev/null +++ b/backend/src/main/java/kr/touroot/image/infrastructure/AwsS3Provider.java @@ -0,0 +1,108 @@ +package kr.touroot.image.infrastructure; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import kr.touroot.global.exception.S3UploadException; +import kr.touroot.image.domain.ImageFile; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Component +public class AwsS3Provider { + + private final S3Client s3Client; + private final String bucket; + private final String imageBaseUri; + private final String tourootStoragePath; + private final String temporaryStoragePath; + private final String imageStoragePath; + + public AwsS3Provider( + S3Client s3Client, + @Value("${cloud.aws.s3.bucket}") String bucket, + @Value("${cloud.aws.s3.image-base-uri}") String imageBaseUri, + @Value("${cloud.aws.s3.base-storage-path}") String tourootStoragePath, + @Value("${cloud.aws.s3.temporary-storage-path}") String temporaryStoragePath, + @Value("${cloud.aws.s3.image-storage-path}") String imageStoragePath + ) { + this.s3Client = s3Client; + this.bucket = bucket; + this.imageBaseUri = imageBaseUri; + this.tourootStoragePath = tourootStoragePath; + this.temporaryStoragePath = temporaryStoragePath; + this.imageStoragePath = imageStoragePath; + } + + public List uploadImages(List files) { + List urls = new ArrayList<>(); + + files.stream() + .map(ImageFile::getFile) + .forEach(file -> { + String newFileName = createNewFileName(file.getOriginalFilename()); + String filePath = tourootStoragePath + temporaryStoragePath + newFileName; + uploadFile(file, filePath, s3Client); + String s3Key = imageBaseUri + temporaryStoragePath + newFileName; + urls.add(s3Key); + }); + return urls; + } + + private String createNewFileName(String fileName) { + return UUID.randomUUID() + fileName.substring(fileName.lastIndexOf(".")); + } + + private void uploadFile(MultipartFile file, String filePath, S3Client s3Client) { + try { + RequestBody requestBody = RequestBody.fromBytes(file.getBytes()); + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(filePath) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + s3Client.putObject(putObjectRequest, requestBody); + } catch (IOException exception) { + throw new S3UploadException("S3에 이미지를 업로드하다 오류가 발생했습니다."); + } + } + + public String copyImageToPermanentStorage(String imageUrl) { + validateS3Path(imageUrl); + String fileName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + String sourceKey = tourootStoragePath + temporaryStoragePath + fileName; + String destinationKey = sourceKey.replace(temporaryStoragePath, imageStoragePath); + copyFile(sourceKey, destinationKey); + return imageUrl.replace(temporaryStoragePath, imageStoragePath); + } + + private void validateS3Path(String imageKey) { + if (!imageKey.startsWith(imageBaseUri + temporaryStoragePath)) { + throw new S3UploadException("S3 이미지 url 형식이 잘못되었습니다."); + } + } + + private void copyFile(String sourceKey, String destinationKey) { + try { + CopyObjectRequest request = CopyObjectRequest.builder() + .sourceBucket(bucket) + .sourceKey(sourceKey) + .destinationBucket(bucket) + .destinationKey(destinationKey) + .build(); + + s3Client.copyObject(request); + } catch (NoSuchKeyException exception) { + throw new S3UploadException("S3 버킷에 복사하려는 사진이 존재하지 않습니다."); + } + } +} diff --git a/backend/src/main/java/kr/touroot/image/service/ImageService.java b/backend/src/main/java/kr/touroot/image/service/ImageService.java new file mode 100644 index 00000000..eee12c56 --- /dev/null +++ b/backend/src/main/java/kr/touroot/image/service/ImageService.java @@ -0,0 +1,22 @@ +package kr.touroot.image.service; + +import java.util.List; +import kr.touroot.image.domain.ImageFile; +import kr.touroot.image.infrastructure.AwsS3Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@Service +public class ImageService { + + private final AwsS3Provider s3Provider; + + public List uploadImages(List files) { + List imageFiles = files.stream() + .map(ImageFile::new) + .toList(); + return s3Provider.uploadImages(imageFiles); + } +} diff --git a/backend/src/main/java/kr/touroot/member/controller/MemberController.java b/backend/src/main/java/kr/touroot/member/controller/MemberController.java new file mode 100644 index 00000000..5668f071 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/controller/MemberController.java @@ -0,0 +1,48 @@ +package kr.touroot.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.member.dto.request.MemberRequest; +import kr.touroot.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "사용자") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberController { + + private final MemberService memberService; + + @Operation(summary = "회원 가입") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 값이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @PostMapping + public ResponseEntity createMember(@Valid @RequestBody MemberRequest request) { + Long id = memberService.createMember(request); + + return ResponseEntity.created(URI.create("/api/v1/members/" + id)) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/member/controller/MyPageController.java b/backend/src/main/java/kr/touroot/member/controller/MyPageController.java new file mode 100644 index 00000000..dce8b53e --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/controller/MyPageController.java @@ -0,0 +1,130 @@ +package kr.touroot.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.member.dto.MyTravelogueResponse; +import kr.touroot.member.dto.ProfileResponse; +import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.member.service.MyPageFacadeService; +import kr.touroot.travelplan.dto.response.PlanResponse; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "마이 페이지") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/member/me") +public class MyPageController { + + private final MyPageFacadeService myPageFacadeService; + + @Operation(summary = "나의 프로필 정보 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "내 프로필 정보 조회에 성공했을 때" + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 프로필 정보 조회에 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @GetMapping("/profile") + public ResponseEntity readProfile(@NotNull MemberAuth memberAuth) { + ProfileResponse data = myPageFacadeService.readProfile(memberAuth); + return ResponseEntity.ok(data); + } + + @Operation(summary = "내 여행기 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "내 여행기 조회에 성공했을 때" + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 조회를 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PageableAsQueryParam + @GetMapping("/travelogues") + public ResponseEntity> readTravelogues( + @NotNull MemberAuth memberAuth, + @Parameter(hidden = true) + @PageableDefault(size = 5, sort = "id", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page data = myPageFacadeService.readTravelogues(memberAuth, pageable); + return ResponseEntity.ok(data); + } + + @Operation(summary = "내 여행 계획 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "내 여행 계획 조회에 성공했을 때" + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 조회를 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PageableAsQueryParam + @GetMapping("/travel-plans") + public ResponseEntity> readTravelPlans( + @NotNull MemberAuth memberAuth, + @Parameter(hidden = true) + @PageableDefault(size = 5, sort = "id", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page data = myPageFacadeService.readTravelPlans(memberAuth, pageable); + return ResponseEntity.ok(data); + } + + @Operation(summary = "나의 프로필 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "내 프로필 정보 수정에 성공했을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 값이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 프로필 정보 수정을 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PatchMapping("/profile") + public ResponseEntity updateProfile( + @Valid @RequestBody ProfileUpdateRequest request, + @NotNull MemberAuth memberAuth + ) { + return ResponseEntity.ok(myPageFacadeService.updateProfile(request, memberAuth)); + } +} diff --git a/backend/src/main/java/kr/touroot/member/domain/LoginType.java b/backend/src/main/java/kr/touroot/member/domain/LoginType.java new file mode 100644 index 00000000..afd7f3de --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/domain/LoginType.java @@ -0,0 +1,5 @@ +package kr.touroot.member.domain; + +public enum LoginType { + KAKAO, DEFAULT +} diff --git a/backend/src/main/java/kr/touroot/member/domain/Member.java b/backend/src/main/java/kr/touroot/member/domain/Member.java new file mode 100644 index 00000000..6262d57d --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/domain/Member.java @@ -0,0 +1,135 @@ +package kr.touroot.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.net.URL; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; + +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Member extends BaseEntity { + + public static final int NICKNAME_MIN_LENGTH = 1; + public static final int NICKNAME_MAX_LENGTH = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long kakaoId; + + private String email; + + private String password; + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private String profileImageUrl; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private LoginType loginType; + + public Member( + Long id, Long kakaoId, String email, String password, String nickname, String url, LoginType loginType + ) { + validate(kakaoId, email, password, nickname, url, loginType); + this.id = id; + this.kakaoId = kakaoId; + this.email = email; + this.password = password; + this.nickname = nickname; + this.profileImageUrl = url; + this.loginType = loginType; + } + + public Member(Long kakaoId, String nickname, String profileImageUrl, LoginType loginType) { + this(null, kakaoId, null, null, nickname, profileImageUrl, loginType); + } + + public Member(String email, String password, String nickname, String profileImageUrl, LoginType loginType) { + this(null, null, email, password, nickname, profileImageUrl, loginType); + } + + private void validate( + Long kakaoId, String email, String password, String nickname, String profileImageUrl, LoginType loginType + ) { + validateByLoginType(kakaoId, email, password, loginType); + validateNickname(nickname); + validateProfileImageUrl(profileImageUrl); + validateNicknameLength(nickname); + validateProfileImageUrl(profileImageUrl); + } + + private void validateNickname(String nickname) { + validateNicknameNotBlank(nickname); + validateNicknameLength(nickname); + } + + private void validateProfileImageUrl(String profileImageUrl) { + validateProfileImageUrlNotBlank(profileImageUrl); + validateProfileImageUrlForm(profileImageUrl); + } + + private void validateByLoginType(Long kakaoId, String email, String password, LoginType loginType) { + if (loginType.equals(LoginType.KAKAO) && kakaoId == null) { + throw new BadRequestException("카카오 ID는 비어 있을 수 없습니다"); + } + + if (loginType.equals(LoginType.DEFAULT) && (email == null || password == null)) { + throw new BadRequestException("이메일과 비밀번호는 비어 있을 수 없습니다."); + } + + if (loginType.equals(LoginType.DEFAULT) && (email.isBlank() || password.isBlank())) { + throw new BadRequestException("이메일과 비밀번호는 비어 있을 수 없습니다."); + } + } + + private void validateNicknameNotBlank(String nickname) { + if (StringUtils.isBlank(nickname)) { + throw new BadRequestException("닉네임은 비어 있을 수 없습니다"); + } + } + + private void validateProfileImageUrlNotBlank(String profileImageUrl) { + if (StringUtils.isBlank(profileImageUrl)) { + throw new BadRequestException("프로필 이미지는 비어 있을 수 없습니다"); + } + } + + private void validateNicknameLength(String nickname) { + if (NICKNAME_MIN_LENGTH > nickname.length() || nickname.length() > NICKNAME_MAX_LENGTH) { + throw new BadRequestException( + "닉네임은 " + NICKNAME_MIN_LENGTH + "자 이상, " + NICKNAME_MAX_LENGTH + "자 이하여야 합니다" + ); + } + } + + private void validateProfileImageUrlForm(String profileImageUrl) { + try { + new URL(profileImageUrl).toURI(); + } catch (Exception e) { + throw new BadRequestException("이미지 url 형식이 잘못되었습니다"); + } + } + + public void changeNickname(String nickname) { + validateNickname(nickname); + this.nickname = nickname; + } +} diff --git a/backend/src/main/java/kr/touroot/member/dto/MyTravelPlanResponse.java b/backend/src/main/java/kr/touroot/member/dto/MyTravelPlanResponse.java new file mode 100644 index 00000000..52b55599 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/dto/MyTravelPlanResponse.java @@ -0,0 +1,19 @@ +package kr.touroot.member.dto; + +import kr.touroot.travelplan.domain.TravelPlan; +import lombok.Builder; + +import java.time.LocalDate; + +@Builder +public record MyTravelPlanResponse(long id, String title, LocalDate startDate, LocalDate endDate) { + + public static MyTravelPlanResponse of(TravelPlan travelPlan, int period) { + return MyTravelPlanResponse.builder() + .id(travelPlan.getId()) + .title(travelPlan.getTitle()) + .startDate(travelPlan.getStartDate()) + .endDate(travelPlan.getStartDate().plusDays(period)) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/member/dto/MyTravelogueResponse.java b/backend/src/main/java/kr/touroot/member/dto/MyTravelogueResponse.java new file mode 100644 index 00000000..3a0c59b9 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/dto/MyTravelogueResponse.java @@ -0,0 +1,23 @@ +package kr.touroot.member.dto; + +import kr.touroot.travelogue.domain.Travelogue; +import lombok.Builder; + +import java.time.format.DateTimeFormatter; + +@Builder +public record MyTravelogueResponse(long id, String title, String thumbnailUrl, String createdAt) { + + public static MyTravelogueResponse from(Travelogue travelogue) { + String createdAt = travelogue.getCreatedAt() + .toLocalDate() + .format(DateTimeFormatter.ofPattern("yyyy.MM.dd")); + + return MyTravelogueResponse.builder() + .id(travelogue.getId()) + .title(travelogue.getTitle()) + .createdAt(createdAt) + .thumbnailUrl(travelogue.getThumbnail()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/member/dto/ProfileResponse.java b/backend/src/main/java/kr/touroot/member/dto/ProfileResponse.java new file mode 100644 index 00000000..3a961ef5 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/dto/ProfileResponse.java @@ -0,0 +1,15 @@ +package kr.touroot.member.dto; + +import kr.touroot.member.domain.Member; +import lombok.Builder; + +@Builder +public record ProfileResponse(String profileImageUrl, String nickname) { + + public static ProfileResponse from(Member member) { + return ProfileResponse.builder() + .profileImageUrl(member.getProfileImageUrl()) + .nickname(member.getNickname()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/member/dto/request/MemberRequest.java b/backend/src/main/java/kr/touroot/member/dto/request/MemberRequest.java new file mode 100644 index 00000000..8708f751 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/dto/request/MemberRequest.java @@ -0,0 +1,28 @@ +package kr.touroot.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import kr.touroot.member.domain.LoginType; +import kr.touroot.member.domain.Member; + +public record MemberRequest( + @Schema(description = "사용자 이메일", example = "email@gmail.com") + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email + String email, + @Schema(description = "사용자 비밀번호", example = "@testpassword1234") + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + String password, + @Schema(description = "사용자 닉네임", example = "뚜리") + @NotBlank(message = "닉네임은 비어있을 수 없습니다.") + String nickname, + @Schema(description = "사용자 프로필 사진 URL", example = "https://dev.touroot.kr/profile-image-ex.png") + @NotBlank(message = "프로필 사진 URL은 비어있을 수 없습니다.") + String profileImageUrl +) { + + public Member toMember(String password) { + return new Member(email, password, nickname, profileImageUrl, LoginType.DEFAULT); + } +} diff --git a/backend/src/main/java/kr/touroot/member/dto/request/ProfileUpdateRequest.java b/backend/src/main/java/kr/touroot/member/dto/request/ProfileUpdateRequest.java new file mode 100644 index 00000000..45926ed6 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/dto/request/ProfileUpdateRequest.java @@ -0,0 +1,11 @@ +package kr.touroot.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record ProfileUpdateRequest( + @Schema(description = "사용자 닉네임", example = "아기뚜리") + @NotBlank(message = "닉네임은 비어있을 수 없습니다.") + String nickname +) { +} diff --git a/backend/src/main/java/kr/touroot/member/repository/MemberRepository.java b/backend/src/main/java/kr/touroot/member/repository/MemberRepository.java new file mode 100644 index 00000000..7ccfa3ea --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/repository/MemberRepository.java @@ -0,0 +1,16 @@ +package kr.touroot.member.repository; + +import java.util.Optional; +import kr.touroot.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + Optional findByKakaoId(Long kakaoId); + + Optional findByEmailAndPassword(String email, String password); + + Optional findByEmail(String email); + + Optional findByNickname(String nickname); +} diff --git a/backend/src/main/java/kr/touroot/member/service/MemberService.java b/backend/src/main/java/kr/touroot/member/service/MemberService.java new file mode 100644 index 00000000..22e8e35e --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/service/MemberService.java @@ -0,0 +1,59 @@ +package kr.touroot.member.service; + +import kr.touroot.authentication.infrastructure.PasswordEncryptor; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import kr.touroot.member.dto.ProfileResponse; +import kr.touroot.member.dto.request.MemberRequest; +import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncryptor passwordEncryptor; + + public Member getById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new BadRequestException("존재하지 않는 사용자입니다.")); + } + + public Long createMember(MemberRequest request) { + validateRequest(request); + String encryptedPassword = passwordEncryptor.encrypt(request.password()); + Member member = request.toMember(encryptedPassword); + + return memberRepository.save(member).getId(); + } + + private void validateRequest(MemberRequest request) { + validateEmailDuplication(request.email()); + validateNicknameDuplication(request.nickname()); + } + + private void validateEmailDuplication(String email) { + if (memberRepository.findByEmail(email).isPresent()) { + throw new BadRequestException("이미 회원 가입되어 있는 이메일입니다."); + } + } + + private void validateNicknameDuplication(String nickname) { + if (memberRepository.findByNickname(nickname).isPresent()) { + throw new BadRequestException("이미 사용 중인 닉네임입니다."); + } + } + + @Transactional + public ProfileResponse updateProfile(ProfileUpdateRequest request, MemberAuth memberAuth) { + Member member = getById(memberAuth.memberId()); + member.changeNickname(request.nickname()); + + return ProfileResponse.from(member); + } +} diff --git a/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java b/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java new file mode 100644 index 00000000..11081475 --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java @@ -0,0 +1,53 @@ +package kr.touroot.member.service; + +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.member.domain.Member; +import kr.touroot.member.dto.MyTravelogueResponse; +import kr.touroot.member.dto.ProfileResponse; +import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.service.TravelogueService; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.dto.response.PlanResponse; +import kr.touroot.travelplan.service.TravelPlanService; +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; + +@RequiredArgsConstructor +@Service +public class MyPageFacadeService { + + private final MemberService memberService; + private final TravelogueService travelogueService; + private final TravelPlanService travelPlanService; + + @Transactional(readOnly = true) + public ProfileResponse readProfile(MemberAuth memberAuth) { + Member member = memberService.getById(memberAuth.memberId()); + return ProfileResponse.from(member); + } + + @Transactional(readOnly = true) + public Page readTravelogues(MemberAuth memberAuth, Pageable pageable) { + Member member = memberService.getById(memberAuth.memberId()); + Page travelogues = travelogueService.findAllByMember(member, pageable); + + return travelogues.map(MyTravelogueResponse::from); + } + + @Transactional(readOnly = true) + public Page readTravelPlans(MemberAuth memberAuth, Pageable pageable) { + Member member = memberService.getById(memberAuth.memberId()); + Page travelPlans = travelPlanService.getAllByAuthor(member, pageable); + + return travelPlans.map((travelPlanService::getTravelPlanResponse)); + } + + @Transactional + public ProfileResponse updateProfile(ProfileUpdateRequest request, MemberAuth memberAuth) { + return memberService.updateProfile(request, memberAuth); + } +} diff --git a/backend/src/main/java/kr/touroot/place/domain/Place.java b/backend/src/main/java/kr/touroot/place/domain/Place.java new file mode 100644 index 00000000..cf8dac71 --- /dev/null +++ b/backend/src/main/java/kr/touroot/place/domain/Place.java @@ -0,0 +1,90 @@ +package kr.touroot.place.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Place extends BaseEntity { + + private static final Pattern LATITUDE_PATTERN = Pattern.compile("^([-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?))$"); + private static final Pattern LONGITUDE_PATTERN = Pattern.compile( + "^([-+]?((1[0-7]\\d(\\.\\d+)?|180(\\.0+)?)|([1-9]?\\d(\\.\\d+)?)))$"); + private static final int PLACE_NAME_MAX_LENGTH = 60; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String latitude; + + @Column(nullable = false) + private String longitude; + + private String googlePlaceId; + + public Place(Long id, String name, String latitude, String longitude, String googlePlaceId) { + validate(name, latitude, longitude, googlePlaceId); + this.id = id; + this.name = name; + this.latitude = latitude; + this.longitude = longitude; + this.googlePlaceId = googlePlaceId; + } + + public Place(String name, String latitude, String longitude, String googlePlaceId) { + this(null, name, latitude, longitude, googlePlaceId); + } + + public Place(String name, String latitude, String longitude) { + this(null, name, latitude, longitude, null); + } + + private void validate(String name, String latitude, String longitude, String googlePlaceId) { + validateNotNull(name, latitude, longitude); + validateBlank(name, latitude, longitude); + validateLatitudeLongitudeFormat(latitude, longitude); + validatePlaceNameLength(name); + } + + private void validateNotNull(String name, String latitude, String longitude) { + if (name == null || latitude == null || longitude == null) { + throw new BadRequestException("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + } + + private void validateBlank(String name, String latitude, String longitude) { + if (name.isBlank() || latitude.isBlank() || longitude.isBlank()) { + throw new BadRequestException("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + } + + private void validateLatitudeLongitudeFormat(String latitude, String longitude) { + Matcher latitudeMatcher = LATITUDE_PATTERN.matcher(latitude); + Matcher longitudeMatcher = LONGITUDE_PATTERN.matcher(longitude); + if (!latitudeMatcher.find() || !longitudeMatcher.find()) { + throw new BadRequestException("위,경도의 형식이 올바르지 않습니다"); + } + } + + private void validatePlaceNameLength(String placeName) { + if (placeName.length() > PLACE_NAME_MAX_LENGTH) { + throw new BadRequestException("장소 이름은 " + PLACE_NAME_MAX_LENGTH + "자 이하여야 합니다"); + } + } +} diff --git a/backend/src/main/java/kr/touroot/place/repository/PlaceRepository.java b/backend/src/main/java/kr/touroot/place/repository/PlaceRepository.java new file mode 100644 index 00000000..85881925 --- /dev/null +++ b/backend/src/main/java/kr/touroot/place/repository/PlaceRepository.java @@ -0,0 +1,10 @@ +package kr.touroot.place.repository; + +import java.util.Optional; +import kr.touroot.place.domain.Place; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PlaceRepository extends JpaRepository { + + Optional findByNameAndLatitudeAndLongitude(String name, String lat, String lng); +} diff --git a/backend/src/main/java/kr/touroot/tag/cache/CacheConfig.java b/backend/src/main/java/kr/touroot/tag/cache/CacheConfig.java new file mode 100644 index 00000000..3fe25432 --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/cache/CacheConfig.java @@ -0,0 +1,25 @@ +package kr.touroot.tag.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager("tag"); + cacheManager.setCaffeine(caffeineConfig()); + + return cacheManager; + } + + private Caffeine caffeineConfig() { + return Caffeine.newBuilder() + .expireAfterWrite(Duration.ofDays(1)); + } +} diff --git a/backend/src/main/java/kr/touroot/tag/controller/TagController.java b/backend/src/main/java/kr/touroot/tag/controller/TagController.java new file mode 100644 index 00000000..3dffd2e6 --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/controller/TagController.java @@ -0,0 +1,63 @@ +package kr.touroot.tag.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.tag.dto.TagCreateRequest; +import kr.touroot.tag.dto.TagResponse; +import kr.touroot.tag.service.TagService; +import lombok.RequiredArgsConstructor; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "태그") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/tags") +public class TagController { + + private final TagService tagService; + + @Operation(summary = "태그 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "태그가 생성이 정상적으로 성공했을 때" + ), + @ApiResponse( + responseCode = "400", + description = "Body에 유효하지 않은 값이 존재하거나 중복된 태그가 존재할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PostMapping + public ResponseEntity createTag(@Valid @RequestBody TagCreateRequest request) { + TagResponse data = tagService.createTag(request); + return ResponseEntity.created(URI.create("/api/v1/tags/" + data.id())) + .body(data); + } + + @Operation(summary = "모든 태그 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "태그가 조회가 정상적으로 성공했을 때" + ) + }) + @GetMapping + public ResponseEntity> readTags() { + List data = tagService.readTags(); + return ResponseEntity.ok(data); + } +} diff --git a/backend/src/main/java/kr/touroot/tag/domain/Tag.java b/backend/src/main/java/kr/touroot/tag/domain/Tag.java new file mode 100644 index 00000000..58bb4fb0 --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/domain/Tag.java @@ -0,0 +1,25 @@ +package kr.touroot.tag.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class Tag { + + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @Column(nullable = false, unique = true) + private String tag; + + public Tag(String tag) { + this(null, tag); + } +} diff --git a/backend/src/main/java/kr/touroot/tag/dto/TagCreateRequest.java b/backend/src/main/java/kr/touroot/tag/dto/TagCreateRequest.java new file mode 100644 index 00000000..5c97f88f --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/dto/TagCreateRequest.java @@ -0,0 +1,15 @@ +package kr.touroot.tag.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import kr.touroot.tag.domain.Tag; + +public record TagCreateRequest( + @Schema(description = "태그 이름", example = "강아지와 함께") + @NotEmpty(message = "태그는 비어있을 수 없습니다.") String tag +) { + + public Tag toTag() { + return new Tag(tag); + } +} diff --git a/backend/src/main/java/kr/touroot/tag/dto/TagResponse.java b/backend/src/main/java/kr/touroot/tag/dto/TagResponse.java new file mode 100644 index 00000000..ad44e28d --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/dto/TagResponse.java @@ -0,0 +1,19 @@ +package kr.touroot.tag.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.touroot.tag.domain.Tag; +import lombok.Builder; + +@Builder +public record TagResponse( + @Schema(description = "생성된 태그의 id", example = "1") Long id, + @Schema(description = "생성된 태그의 내용", example = "신나는 강아지 산책") String tag +) { + + public static TagResponse from(Tag tag) { + return TagResponse.builder() + .id(tag.getId()) + .tag(tag.getTag()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/tag/repository/TagRepository.java b/backend/src/main/java/kr/touroot/tag/repository/TagRepository.java new file mode 100644 index 00000000..23430a27 --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/repository/TagRepository.java @@ -0,0 +1,9 @@ +package kr.touroot.tag.repository; + +import kr.touroot.tag.domain.Tag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagRepository extends JpaRepository { + + boolean existsByTag(String tag); +} diff --git a/backend/src/main/java/kr/touroot/tag/service/TagService.java b/backend/src/main/java/kr/touroot/tag/service/TagService.java new file mode 100644 index 00000000..645dd53c --- /dev/null +++ b/backend/src/main/java/kr/touroot/tag/service/TagService.java @@ -0,0 +1,43 @@ +package kr.touroot.tag.service; + +import java.util.List; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.tag.domain.Tag; +import kr.touroot.tag.dto.TagCreateRequest; +import kr.touroot.tag.dto.TagResponse; +import kr.touroot.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class TagService { + + private final TagRepository tagRepository; + + @Transactional + public TagResponse createTag(TagCreateRequest tagCreateRequest) { + validateDuplicated(tagCreateRequest); + Tag savedTag = tagRepository.save(tagCreateRequest.toTag()); + + return TagResponse.from(savedTag); + } + + private void validateDuplicated(TagCreateRequest tagCreateRequest) { + if (tagRepository.existsByTag(tagCreateRequest.tag())) { + throw new BadRequestException("이미 존재하는 태그입니다."); + } + } + + @Cacheable(cacheNames = "tag") + @Transactional(readOnly = true) + public List readTags() { + return tagRepository.findAll().stream() + .map(TagResponse::from) + .toList(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/controller/TravelogueController.java b/backend/src/main/java/kr/touroot/travelogue/controller/TravelogueController.java new file mode 100644 index 00000000..52976aeb --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/controller/TravelogueController.java @@ -0,0 +1,268 @@ +package kr.touroot.travelogue.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.dto.request.TravelogueSearchRequest; +import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; +import kr.touroot.travelogue.dto.response.TravelogueResponse; +import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; +import kr.touroot.travelogue.service.TravelogueFacadeService; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "여행기") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/travelogues") +public class TravelogueController { + + private final TravelogueFacadeService travelogueFacadeService; + + @Operation(summary = "여행기 작성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 값이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @PostMapping + public ResponseEntity createTravelogue( + @Valid MemberAuth member, + @Valid @RequestBody TravelogueRequest request + ) { + TravelogueResponse response = travelogueFacadeService.createTravelogue(member, request); + + return ResponseEntity.created(URI.create("/api/v1/travelogues/" + response.id())) + .body(response); + } + + @Operation(summary = "여행기 좋아요") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 여행기 ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 좋아요를 할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @PostMapping("/{id}/like") + public ResponseEntity likeTravelogue(@PathVariable Long id, @Valid MemberAuth member) { + return ResponseEntity.ok() + .body(travelogueFacadeService.likeTravelogue(id, member)); + } + + @Operation(summary = "여행기 상세 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 여행기 ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @GetMapping("/{id}") + public ResponseEntity findTravelogue(@PathVariable Long id) { + return ResponseEntity.ok(travelogueFacadeService.findTravelogueById(id)); + } + + @Operation(summary = "여행기 상세 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 여행기 ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @GetMapping(value = "/{id}", headers = {HttpHeaders.AUTHORIZATION}) + public ResponseEntity findTravelogue(@PathVariable Long id, MemberAuth member) { + return ResponseEntity.ok(travelogueFacadeService.findTravelogueById(id, member)); + } + + @Operation(summary = "여행기 메인 페이지 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "올바르지 않은 페이지네이션 옵션으로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @PageableAsQueryParam + @GetMapping + public ResponseEntity> findMainPageTravelogues( + @Parameter(hidden = true) + @PageableDefault(size = 5, sort = "id", direction = Direction.DESC) + Pageable pageable + ) { + return ResponseEntity.ok(travelogueFacadeService.findSimpleTravelogues(pageable)); + } + + @Operation(summary = "여행기 메인 페이지 필터링") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "올바르지 않은 페이지네이션 옵션으로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @PageableAsQueryParam + @GetMapping(params = {"tag-filter"}) + public ResponseEntity> findMainPageTravelogues( + @Parameter(hidden = true) + @PageableDefault(size = 5, sort = "id", direction = Direction.DESC) + Pageable pageable, + @RequestParam(name = "tag-filter", required = false) List tagFilter + ) { + return ResponseEntity.ok(travelogueFacadeService.findSimpleTravelogues(tagFilter, pageable)); + } + + @Operation(summary = "여행기 검색") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "올바르지 않은 페이지네이션 옵션 또는 키워드로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @PageableAsQueryParam + @GetMapping("/search") + public ResponseEntity> findTraveloguesByKeyword( + @Parameter(hidden = true) + @PageableDefault(size = 5, sort = "id", direction = Direction.DESC) + Pageable pageable, + @Valid + TravelogueSearchRequest searchRequest + ) { + return ResponseEntity.ok(travelogueFacadeService.findSimpleTravelogues(pageable, searchRequest)); + } + + @Operation(summary = "여행기 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 값이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "작성자가 아닌 사용자가 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PutMapping("/{id}") + public ResponseEntity updateTravelogue( + @PathVariable Long id, + @Valid MemberAuth member, + @Valid @RequestBody TravelogueRequest request + ) { + return ResponseEntity.ok(travelogueFacadeService.updateTravelogue(id, member, request)); + } + + @Operation(summary = "여행기 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 여행기 ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "작성자가 아닌 사용자가 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @DeleteMapping("/{id}") + public ResponseEntity deleteTravelogue(@PathVariable Long id, MemberAuth memberAuth) { + travelogueFacadeService.deleteTravelogueById(id, memberAuth); + return ResponseEntity.noContent() + .build(); + } + + @Operation(summary = "여행기 좋아요 취소") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 여행기 ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 좋아요를 취소 할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @DeleteMapping("/{id}/like") + public ResponseEntity unlikeTravelogue(@PathVariable Long id, @Valid MemberAuth member) { + return ResponseEntity.ok() + .body(travelogueFacadeService.unlikeTravelogue(id, member)); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/Travelogue.java b/backend/src/main/java/kr/touroot/travelogue/domain/Travelogue.java new file mode 100644 index 00000000..1a03b001 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/Travelogue.java @@ -0,0 +1,113 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.net.URL; +import java.util.Objects; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travelogue SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class Travelogue extends BaseEntity { + + private static final int MIN_TITLE_LENGTH = 1; + private static final int MAX_TITLE_LENGTH = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Member author; + + @Column(nullable = false, length = 20) + private String title; + + @Column(nullable = false) + private String thumbnail; + + public Travelogue(Long id, Member author, String title, String thumbnail) { + validate(author, title, thumbnail); + this.id = id; + this.author = author; + this.title = title; + this.thumbnail = thumbnail; + } + + public Travelogue(Member author, String title, String thumbnail) { + this(null, author, title, thumbnail); + } + + public void update(String title, String thumbnail) { + this.title = title; + this.thumbnail = thumbnail; + } + + private void validate(Member author, String title, String thumbnail) { + validateNotNull(author, title, thumbnail); + validateNotBlank(title, thumbnail); + validateTitleLength(title); + validateThumbnailFormat(thumbnail); + } + + private void validateNotNull(Member author, String title, String thumbnail) { + if (title == null || thumbnail == null || author == null) { + throw new BadRequestException("작성자, 여행기 제목, 그리고 여행기 썸네일은 비어있을 수 없습니다"); + } + } + + private void validateNotBlank(String title, String thumbnail) { + if (title.isBlank() || thumbnail.isBlank()) { + throw new BadRequestException("여행기 제목, 여행기 썸네일은 비어있을 수 없습니다"); + } + } + + private void validateTitleLength(String title) { + if (MIN_TITLE_LENGTH > title.length() || title.length() > MAX_TITLE_LENGTH) { + throw new BadRequestException("여행기 제목은 " + MIN_TITLE_LENGTH + "자 이상, " + MAX_TITLE_LENGTH + "자 이하여야 합니다"); + } + } + + private void validateThumbnailFormat(String thumbnailUrl) { + try { + new URL(thumbnailUrl).toURI(); + } catch (Exception e) { + throw new BadRequestException("이미지 url 형식이 잘못되었습니다"); + } + } + + public boolean isAuthor(Member author) { + return Objects.equals(author.getId(), this.author.getId()); + } + + public String getAuthorNickname() { + return author.getNickname(); + } + + public String getAuthorProfileImageUrl() { + return author.getProfileImageUrl(); + } + + public Long getAuthorId() { + return author.getId(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueDay.java b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueDay.java new file mode 100644 index 00000000..256abbdd --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueDay.java @@ -0,0 +1,66 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travelogue_day SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TravelogueDay extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "DAY_ORDER", nullable = false) + private Integer order; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Travelogue travelogue; + + private TravelogueDay(Long id, Integer order, Travelogue travelogue) { + validate(order, travelogue); + this.id = id; + this.order = order; + this.travelogue = travelogue; + } + + public TravelogueDay(Integer order, Travelogue travelogue) { + this(null, order, travelogue); + } + + private void validate(Integer order, Travelogue travelogue) { + validateNotNull(order, travelogue); + validateOrderRange(order); + } + + private void validateNotNull(Integer order, Travelogue travelogue) { + if (order == null || travelogue == null) { + throw new BadRequestException("여행 날짜가 속한 여행기와 여행 날짜의 순서는 비어 있을 수 없습니다"); + } + } + + private void validateOrderRange(Integer order) { + if (order < 0) { + throw new BadRequestException("여행 날짜의 순서는 음수 일 수 없습니다"); + } + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueLike.java b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueLike.java new file mode 100644 index 00000000..31acdd91 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueLike.java @@ -0,0 +1,40 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.touroot.member.domain.Member; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"TRAVELOGUE_ID", "LIKER_ID"})}) +@Entity +public class TravelogueLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "TRAVELOGUE_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Travelogue travelogue; + + @JoinColumn(name = "LIKER_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Member liker; + + public TravelogueLike(Travelogue travelogue, Member liker) { + this(null, travelogue, liker); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/TraveloguePhoto.java b/backend/src/main/java/kr/touroot/travelogue/domain/TraveloguePhoto.java new file mode 100644 index 00000000..62acc000 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/TraveloguePhoto.java @@ -0,0 +1,70 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travelogue_photo SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TraveloguePhoto extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "PHOTO_KEY", nullable = false) + private String key; + + @Column(name = "PHOTO_ORDER", nullable = false) + private Integer order; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TraveloguePlace traveloguePlace; + + private TraveloguePhoto(Long id, String key, Integer order, TraveloguePlace traveloguePlace) { + validate(order, key, traveloguePlace); + this.id = id; + this.key = key; + this.order = order; + this.traveloguePlace = traveloguePlace; + } + + public TraveloguePhoto(Integer order, String key, TraveloguePlace traveloguePlace) { + this(null, key, order, traveloguePlace); + } + + private void validate(Integer order, String key, TraveloguePlace traveloguePlace) { + validateNotNull(order, key, traveloguePlace); + validateOrderRange(order); + } + + private void validateNotNull(Integer order, String key, TraveloguePlace traveloguePlace) { + if (order == null || key == null || traveloguePlace == null) { + throw new BadRequestException("여행 장소의 사진에서 순서와 키, 그리고 사진이 속한 여행 장소는 비어 있을 수 없습니다"); + } + } + + private void validateOrderRange(Integer order) { + if (order < 0) { + throw new BadRequestException("여행 장소에 속하는 사진의 순서는 음수가 될 수 없습니다"); + } + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/TraveloguePlace.java b/backend/src/main/java/kr/touroot/travelogue/domain/TraveloguePlace.java new file mode 100644 index 00000000..f88fb406 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/TraveloguePlace.java @@ -0,0 +1,97 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.place.domain.Place; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travelogue_place SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TraveloguePlace extends BaseEntity { + + private static final int MAX_DESCRIPTION_LENGTH = 300; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "PLACE_ORDER", nullable = false) + private Integer order; + + @Column(columnDefinition = "VARCHAR(300)") + private String description; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Place place; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelogueDay travelogueDay; + + public TraveloguePlace(Long id, Integer order, String description, Place place, TravelogueDay travelogueDay) { + validate(order, description, place, travelogueDay); + this.id = id; + this.order = order; + this.description = description; + this.place = place; + this.travelogueDay = travelogueDay; + } + + public TraveloguePlace(Integer order, String description, Place place, TravelogueDay travelogueDay) { + this(null, order, description, place, travelogueDay); + } + + private void validate(Integer order, String description, Place place, TravelogueDay travelogueDay) { + validateNotNull(order, place, travelogueDay); + validateOrderRange(order); + validateDescriptionLength(description); + } + + private void validateNotNull(Integer order, Place place, TravelogueDay travelogueDay) { + if (order == null || place == null || travelogueDay == null) { + throw new BadRequestException("여행기 장소에서 순서와 장소 상세 정보, 그리고 방문 날짜는 비어 있을 수 없습니다"); + } + } + + private void validateOrderRange(Integer order) { + if (order < 0) { + throw new BadRequestException("여행 장소의 순서는 음수일 수 없습니다"); + } + } + + private void validateDescriptionLength(String description) { + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new BadRequestException("여행 장소에 대한 설명은 " + MAX_DESCRIPTION_LENGTH + "자를 넘길 수 없습니다"); + } + } + + public String getName() { + return place.getName(); + } + + public String getLatitude() { + return place.getLatitude(); + } + + public String getLongitude() { + return place.getLongitude(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueTag.java b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueTag.java new file mode 100644 index 00000000..ac289cf6 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueTag.java @@ -0,0 +1,37 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.touroot.tag.domain.Tag; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelogueTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "TRAVELOGUE_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Travelogue travelogue; + + @JoinColumn(name = "TAG_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Tag tag; + + public TravelogueTag(Travelogue travelogue, Tag tag) { + this(null, travelogue, tag); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueDayRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueDayRequest.java new file mode 100644 index 00000000..bbdf371f --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueDayRequest.java @@ -0,0 +1,22 @@ +package kr.touroot.travelogue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; + +public record TravelogueDayRequest( + @Schema(description = "여행기 장소 목록") + @NotNull(message = "여행기 장소 목록은 비어있을 수 없습니다.") + @Size(message = "여행기 장소는 최소 한 곳은 포함되어야 합니다.", min = 1) + @Valid + List places +) { + + public TravelogueDay toTravelogueDay(int order, Travelogue travelogue) { + return new TravelogueDay(order, travelogue); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePhotoRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePhotoRequest.java new file mode 100644 index 00000000..def693d2 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePhotoRequest.java @@ -0,0 +1,11 @@ +package kr.touroot.travelogue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TraveloguePhotoRequest( + @Schema(description = "여행기 장소 사진 URL", example = "S3 이미지 URL") + @NotBlank(message = "여행기 장소 사진 URL 값은 비어있을 수 없습니다.") + String url +) { +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java new file mode 100644 index 00000000..7fef381d --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java @@ -0,0 +1,38 @@ +package kr.touroot.travelogue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.touroot.place.domain.Place; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePlace; + +public record TraveloguePlaceRequest( + @Schema(description = "여행기 장소 이름", example = "선릉 캠퍼스") + @NotBlank(message = "여행기 장소 이름은 비어있을 수 없습니다.") + @Size(message = "장소 이름은 60자를 초과할 수 없습니다", max = 60) + String placeName, + @Schema(description = "여행기 장소 위치 정보") + @NotNull(message = "여행기 장소 위치 정보는 비어있을 수 없습니다.") + @Valid + TraveloguePositionRequest position, + @Schema(description = "여행기 장소 설명", example = "성담 빌딩에 위치한 선릉 캠퍼스입니다.") + @Size(message = "장소 설명은 300글자 이하입니다.", max = 300) + String description, + @Schema(description = "여행기 장소 사진") + @Size(message = "여행기 장소 사진은 최대 10개입니다.", max = 10) + @Valid + List photoUrls +) { + + public TraveloguePlace toTraveloguePlace(int order, Place place, TravelogueDay travelogueDay) { + return new TraveloguePlace(order, description, place, travelogueDay); + } + + public Place toPlace() { + return new Place(placeName, position.lat(), position.lng()); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePositionRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePositionRequest.java new file mode 100644 index 00000000..ab42446c --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePositionRequest.java @@ -0,0 +1,14 @@ +package kr.touroot.travelogue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TraveloguePositionRequest( + @Schema(description = "여행기 장소 위도", example = "37.5175896") + @NotBlank(message = "여행기 장소 위도는 비어있을 수 없습니다.") + String lat, + @Schema(description = "여행기 장소 경도", example = "127.0867236") + @NotBlank(message = "여행기 장소 경도는 비어있을 수 없습니다.") + String lng +) { +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueRequest.java new file mode 100644 index 00000000..8e896c09 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueRequest.java @@ -0,0 +1,34 @@ +package kr.touroot.travelogue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; + +public record TravelogueRequest( + @Schema(description = "여행기 제목", example = "서울 강남 여행기") + @NotBlank(message = "여행기 제목은 비어있을 수 없습니다.") + @Size(message = "여행기 제목은 20자를 초과할 수 없습니다.", max = 20) + String title, + @Schema(description = "여행기 썸네일", example = "S3 이미지 URL") + @NotNull(message = "여행기 썸네일은 비어있을 수 없습니다.") + String thumbnail, + @Schema(description = "선택된 여행기 태그의 id 목록", example = "[1, 2, 3]") + @NotNull(message = "여행기 태그 필드는 비어있을 수 없습니다.") + @Size(max = 5, message = "여행기 태그는 최대 5개까지 입력할 수 있습니다.") + List tags, + @Schema(description = "여행기 일자 목록") + @NotNull(message = "여행기 일자 목록은 비어있을 수 없습니다.") + @Size(message = "여행기 일자는 최소 1일은 포함되어야 합니다.", min = 1) + @Valid + List days +) { + + public Travelogue toTravelogueOf(Member author, String url) { + return new Travelogue(author, title, url); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java new file mode 100644 index 00000000..d393c200 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java @@ -0,0 +1,11 @@ +package kr.touroot.travelogue.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TravelogueSearchRequest( + @NotBlank(message = "검색어는 2글자 이상이어야 합니다.") + @Size(min = 2, message = "검색어는 2글자 이상이어야 합니다.") + String keyword +) { +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueDayResponse.java b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueDayResponse.java new file mode 100644 index 00000000..b3a075f0 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueDayResponse.java @@ -0,0 +1,22 @@ +package kr.touroot.travelogue.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import kr.touroot.travelogue.domain.TravelogueDay; +import lombok.Builder; + +@Builder +public record TravelogueDayResponse( + @Schema(description = "여행기 일자 ID", example = "1") + Long id, + @Schema(description = "여행기 장소 목록") + List places +) { + + public static TravelogueDayResponse of(TravelogueDay day, List places) { + return TravelogueDayResponse.builder() + .id(day.getId()) + .places(places) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueLikeResponse.java b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueLikeResponse.java new file mode 100644 index 00000000..78afb540 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueLikeResponse.java @@ -0,0 +1,11 @@ +package kr.touroot.travelogue.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TravelogueLikeResponse( + @Schema(description = "사용자의 좋아요 여부", example = "true") + Boolean isLiked, + @Schema(description = "여행기의 좋아요 수", example = "10") + Long likeCount +) { +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/response/TraveloguePlaceResponse.java b/backend/src/main/java/kr/touroot/travelogue/dto/response/TraveloguePlaceResponse.java new file mode 100644 index 00000000..d7b62ed1 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/response/TraveloguePlaceResponse.java @@ -0,0 +1,29 @@ +package kr.touroot.travelogue.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Builder; +import kr.touroot.travelogue.domain.TraveloguePlace; + +@Builder +public record TraveloguePlaceResponse( + @Schema(description = "여행기 장소 ID", example = "1") + Long id, + @Schema(description = "여행기 장소 이름", example = "선릉 캠퍼스") + String placeName, + @Schema(description = "여행기 장소 설명", example = "성담 빌딩에 위치한 선릉 캠퍼스입니다.") + String description, + TraveloguePositionResponse position, + List photoUrls +) { + + public static TraveloguePlaceResponse of(TraveloguePlace place, List photoUrls) { + return TraveloguePlaceResponse.builder() + .id(place.getId()) + .placeName(place.getName()) + .description(place.getDescription()) + .position(TraveloguePositionResponse.from(place)) + .photoUrls(photoUrls) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/response/TraveloguePositionResponse.java b/backend/src/main/java/kr/touroot/travelogue/dto/response/TraveloguePositionResponse.java new file mode 100644 index 00000000..3f7f3112 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/response/TraveloguePositionResponse.java @@ -0,0 +1,24 @@ +package kr.touroot.travelogue.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.touroot.travelogue.domain.TraveloguePlace; +import lombok.Builder; + +@Builder +public record TraveloguePositionResponse( + @Schema(description = "여행기 장소 상세 ID", example = "1") + Long id, + @Schema(description = "여행기 장소 위도", example = "37.5175896") + String lat, + @Schema(description = "여행기 장소 설명", example = "127.0867236") + String lng +) { + + public static TraveloguePositionResponse from(TraveloguePlace place) { + return TraveloguePositionResponse.builder() + .id(place.getPlace().getId()) + .lat(place.getLatitude()) + .lng(place.getLongitude()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueResponse.java b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueResponse.java new file mode 100644 index 00000000..d6ee6a34 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueResponse.java @@ -0,0 +1,55 @@ +package kr.touroot.travelogue.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; +import kr.touroot.tag.dto.TagResponse; +import kr.touroot.travelogue.domain.Travelogue; +import lombok.Builder; + +@Builder +public record TravelogueResponse( + @Schema(description = "여행기 ID", example = "1") + Long id, + @Schema(description = "여행기 제목", example = "서울 강남 여행기") + String title, + @Schema(description = "작성자 ID", example = "1") + Long authorId, + @Schema(description = "작성자 닉네임", example = "지니") + String authorNickname, + @Schema(description = "작성자 프로필 사진 URL", example = "https://dev.touroot.kr/images/profile.png") + String authorProfileImageUrl, + @Schema(description = "여행기 썸네일 링크", example = "https://dev.touroot.kr/images/thumbnail.png") + String thumbnail, + @Schema(description = "작성 날짜") + LocalDate createdAt, + @Schema(description = "여행기 태그") + List tags, + @Schema(description = "여행기 일자 목록") + List days, + @Schema(description = "여행기 좋아요 숫자", example = "10") + Long likeCount, + @Schema(description = "사용자의 여행기 좋아요 여부", example = "true") + Boolean isLiked +) { + + public static TravelogueResponse of( + Travelogue travelogue, + List days, + List tags, + TravelogueLikeResponse like) { + return TravelogueResponse.builder() + .id(travelogue.getId()) + .createdAt(travelogue.getCreatedAt().toLocalDate()) + .authorId(travelogue.getAuthorId()) + .authorNickname(travelogue.getAuthorNickname()) + .authorProfileImageUrl(travelogue.getAuthorProfileImageUrl()) + .title(travelogue.getTitle()) + .thumbnail(travelogue.getThumbnail()) + .days(days) + .tags(tags) + .likeCount(like.likeCount()) + .isLiked(like.isLiked()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueSimpleResponse.java b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueSimpleResponse.java new file mode 100644 index 00000000..fe7c48c9 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/dto/response/TravelogueSimpleResponse.java @@ -0,0 +1,41 @@ +package kr.touroot.travelogue.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import kr.touroot.tag.dto.TagResponse; +import kr.touroot.travelogue.domain.Travelogue; +import lombok.Builder; + +@Builder +public record TravelogueSimpleResponse( + @Schema(description = "여행기 ID", example = "1") + Long id, + @Schema(description = "여행기 제목", example = "서울 강남 여행기") + String title, + @Schema(description = "여행기 썸네일 링크", example = "https://dev.touroot.kr/images/thumbnail.png") + String thumbnail, + @Schema(description = "작성자 닉네임", example = "지니") + String authorNickname, + @Schema(description = "작성자 프로필 사진 URL", example = "https://dev.touroot.kr/images/profile.png") + String authorProfileUrl, + @Schema(description = "여행기 태그 목록") + List tags, + @Schema(description = "작성자 프로필 사진 URL", example = "10") + Long likeCount +) { + + public static TravelogueSimpleResponse of( + Travelogue travelogue, + List tags, + TravelogueLikeResponse like) { + return TravelogueSimpleResponse.builder() + .id(travelogue.getId()) + .title(travelogue.getTitle()) + .thumbnail(travelogue.getThumbnail()) + .authorNickname(travelogue.getAuthorNickname()) + .authorProfileUrl(travelogue.getAuthorProfileImageUrl()) + .tags(tags) + .likeCount(like.likeCount()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueDayRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueDayRepository.java new file mode 100644 index 00000000..f1f65faf --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueDayRepository.java @@ -0,0 +1,13 @@ +package kr.touroot.travelogue.repository; + +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelogueDayRepository extends JpaRepository { + + List findByTravelogue(Travelogue travelogue); + + void deleteAllByTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java new file mode 100644 index 00000000..e47beb76 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java @@ -0,0 +1,15 @@ +package kr.touroot.travelogue.repository; + +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueLike; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelogueLikeRepository extends JpaRepository { + + Long countByTravelogue(Travelogue travelogue); + + boolean existsByTravelogueAndLiker(Travelogue travelogue, Member liker); + + void deleteByTravelogueAndLiker(Travelogue travelogue, Member liker); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TraveloguePhotoRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TraveloguePhotoRepository.java new file mode 100644 index 00000000..c9324a7e --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TraveloguePhotoRepository.java @@ -0,0 +1,14 @@ +package kr.touroot.travelogue.repository; + +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TraveloguePhoto; +import kr.touroot.travelogue.domain.TraveloguePlace; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TraveloguePhotoRepository extends JpaRepository { + + List findByTraveloguePlace(TraveloguePlace traveloguePlace); + + void deleteAllByTraveloguePlaceTravelogueDayTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TraveloguePlaceRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TraveloguePlaceRepository.java new file mode 100644 index 00000000..697f538c --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TraveloguePlaceRepository.java @@ -0,0 +1,14 @@ +package kr.touroot.travelogue.repository; + +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePlace; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TraveloguePlaceRepository extends JpaRepository { + + List findByTravelogueDay(TravelogueDay travelogueDay); + + void deleteAllByTravelogueDayTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueRepository.java new file mode 100644 index 00000000..60609f7b --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueRepository.java @@ -0,0 +1,12 @@ +package kr.touroot.travelogue.repository; + +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelogueRepository extends JpaRepository { + + Page findAllByAuthor(Member author, Pageable pageable); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueTagRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueTagRepository.java new file mode 100644 index 00000000..2f3f6fb7 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueTagRepository.java @@ -0,0 +1,11 @@ +package kr.touroot.travelogue.repository; + +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelogueTagRepository extends JpaRepository { + + List findAllByTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java new file mode 100644 index 00000000..fe575c9c --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java @@ -0,0 +1,13 @@ +package kr.touroot.travelogue.repository.query; + +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface TravelogueQueryRepository { + + Page findByTitleContaining(String keyword, Pageable pageable); + + Page findAllByTag(List tagFilter, Pageable pageable); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java new file mode 100644 index 00000000..427cca74 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java @@ -0,0 +1,54 @@ +package kr.touroot.travelogue.repository.query; + +import static kr.touroot.travelogue.domain.QTravelogue.travelogue; +import static kr.touroot.travelogue.domain.QTravelogueTag.travelogueTag; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class TravelogueQueryRepositoryImpl implements TravelogueQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Page findByTitleContaining(String keyword, Pageable pageable) { + List results = jpaQueryFactory.selectFrom(travelogue) + .where(Expressions.stringTemplate("replace({0}, ' ', '')", travelogue.title) + .containsIgnoreCase(keyword.replace(" ", ""))) + .orderBy(travelogue.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(results, pageable, results.size()); + } + + @Override + public Page findAllByTag(List tagFilter, Pageable pageable) { + List results = jpaQueryFactory.select(travelogue) + .from(travelogueTag) + .where(travelogueTag.tag.id.in(tagFilter)) + .groupBy(travelogueTag.travelogue) + .having(isSameCountWithFilter(tagFilter)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(results, pageable, results.size()); + } + + private BooleanExpression isSameCountWithFilter(List tagFilter) { + return travelogueTag.travelogue.count() + .eq(Long.valueOf(tagFilter.size())); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueTagQueryRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueTagQueryRepository.java new file mode 100644 index 00000000..dcf4aa09 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueTagQueryRepository.java @@ -0,0 +1,8 @@ +package kr.touroot.travelogue.repository.query; + +import kr.touroot.travelogue.domain.Travelogue; + +public interface TravelogueTagQueryRepository { + + void deleteAllByTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueTagQueryRepositoryImpl.java b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueTagQueryRepositoryImpl.java new file mode 100644 index 00000000..9ba91e0c --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueTagQueryRepositoryImpl.java @@ -0,0 +1,22 @@ +package kr.touroot.travelogue.repository.query; + +import static kr.touroot.travelogue.domain.QTravelogueTag.travelogueTag; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.touroot.travelogue.domain.Travelogue; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class TravelogueTagQueryRepositoryImpl implements TravelogueTagQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public void deleteAllByTravelogue(Travelogue travelogue) { + jpaQueryFactory.delete(travelogueTag) + .where(travelogueTag.travelogue.eq(travelogue)) + .execute(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueDayService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueDayService.java new file mode 100644 index 00000000..146c70d1 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueDayService.java @@ -0,0 +1,53 @@ +package kr.touroot.travelogue.service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.repository.TravelogueDayRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TravelogueDayService { + + private final TravelogueDayRepository travelogueDayRepository; + + @Transactional + public Map> createDays( + List requests, + Travelogue travelogue + ) { + Map> daysWithPlaceRequests = new LinkedHashMap<>(); + + for (int i = 0; i < requests.size(); i++) { + TravelogueDayRequest request = requests.get(i); + TravelogueDay travelogueDay = request.toTravelogueDay(i, travelogue); + daysWithPlaceRequests.put(travelogueDayRepository.save(travelogueDay), request.places()); + } + + return daysWithPlaceRequests; + } + + @Transactional(readOnly = true) + public List findDaysByTravelogue(Travelogue travelogue) { + return travelogueDayRepository.findByTravelogue(travelogue); + } + + @Transactional(readOnly = true) + public TravelogueDay findDayById(Long id) { + return travelogueDayRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행기 일자입니다.")); + } + + @Transactional + public void deleteAllByTravelogue(Travelogue travelogue) { + travelogueDayRepository.deleteAllByTravelogue(travelogue); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java new file mode 100644 index 00000000..5c0bbe07 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java @@ -0,0 +1,195 @@ +package kr.touroot.travelogue.service; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.member.domain.Member; +import kr.touroot.member.service.MemberService; +import kr.touroot.tag.dto.TagResponse; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePhoto; +import kr.touroot.travelogue.domain.TraveloguePlace; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.dto.request.TravelogueSearchRequest; +import kr.touroot.travelogue.dto.response.TravelogueDayResponse; +import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; +import kr.touroot.travelogue.dto.response.TraveloguePlaceResponse; +import kr.touroot.travelogue.dto.response.TravelogueResponse; +import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; +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; + +@RequiredArgsConstructor +@Service +public class TravelogueFacadeService { + + private final TravelogueService travelogueService; + private final TravelogueDayService travelogueDayService; + private final TraveloguePlaceService traveloguePlaceService; + private final TraveloguePhotoService traveloguePhotoService; + private final TravelogueTagService travelogueTagService; + private final TravelogueLikeService travelogueLikeService; + private final MemberService memberService; + + @Transactional + public TravelogueResponse createTravelogue(MemberAuth member, TravelogueRequest request) { + Member author = memberService.getById(member.memberId()); + Travelogue travelogue = travelogueService.createTravelogue(author, request); + List tags = travelogueTagService.createTravelogueTags(travelogue, request.tags()); + TravelogueLikeResponse like = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, author); + return TravelogueResponse.of(travelogue, createDays(request.days(), travelogue), tags, like); + } + + private List createDays(List requests, Travelogue travelogue) { + Map> days = travelogueDayService.createDays(requests, travelogue); + + return days.keySet() + .stream() + .map(day -> TravelogueDayResponse.of(day, createPlaces(days.get(day), day))) + .toList(); + } + + private List createPlaces(List requests, TravelogueDay day) { + Map> places = traveloguePlaceService.createPlaces(requests, day); + + return places.keySet() + .stream() + .map(place -> TraveloguePlaceResponse.of(place, createPhotos(places.get(place), place))) + .toList(); + } + + private List createPhotos(List requests, TraveloguePlace place) { + List photos = traveloguePhotoService.createPhotos(requests, place); + + return photos.stream() + .map(TraveloguePhoto::getKey) + .toList(); + } + + @Transactional + public TravelogueLikeResponse likeTravelogue(Long travelogueId, MemberAuth member) { + Travelogue travelogue = travelogueService.getTravelogueById(travelogueId); + Member liker = memberService.getById(member.memberId()); + + return travelogueLikeService.likeTravelogue(travelogue, liker); + } + + @Transactional(readOnly = true) + public TravelogueResponse findTravelogueById(Long id) { + Travelogue travelogue = travelogueService.getTravelogueById(id); + return getTravelogueResponse(travelogue); + } + + @Transactional(readOnly = true) + public TravelogueResponse findTravelogueById(Long id, MemberAuth member) { + Travelogue travelogue = travelogueService.getTravelogueById(id); + return getTravelogueResponse(travelogue, member); + } + + private TravelogueResponse getTravelogueResponse(Travelogue travelogue) { + List tagResponses = travelogueTagService.readTagByTravelogue(travelogue); + TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogue(travelogue); + return TravelogueResponse.of(travelogue, findDaysOfTravelogue(travelogue), tagResponses, likeResponse); + } + + private TravelogueResponse getTravelogueResponse(Travelogue travelogue, MemberAuth member) { + Member liker = memberService.getById(member.memberId()); + + List tagResponses = travelogueTagService.readTagByTravelogue(travelogue); + TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, liker); + return TravelogueResponse.of(travelogue, findDaysOfTravelogue(travelogue), tagResponses, likeResponse); + } + + private List findDaysOfTravelogue(Travelogue travelogue) { + List travelogueDays = travelogueDayService.findDaysByTravelogue(travelogue); + + return travelogueDays.stream() + .sorted(Comparator.comparing(TravelogueDay::getOrder)) + .map(day -> TravelogueDayResponse.of(day, findPlacesOfTravelogueDay(day))) + .toList(); + } + + private List findPlacesOfTravelogueDay(TravelogueDay travelogueDay) { + List places = traveloguePlaceService.findTraveloguePlacesByDay(travelogueDay); + + return places.stream() + .sorted(Comparator.comparing(TraveloguePlace::getOrder)) + .map(place -> TraveloguePlaceResponse.of(place, findPhotoUrlsOfTraveloguePlace(place))) + .toList(); + } + + private List findPhotoUrlsOfTraveloguePlace(TraveloguePlace place) { + return traveloguePhotoService.findPhotoUrlsByPlace(place); + } + + @Transactional(readOnly = true) + public Page findSimpleTravelogues(Pageable pageable) { + Page travelogues = travelogueService.findAll(pageable); + return travelogues.map(this::getTravelogueSimpleResponse); + } + + @Transactional(readOnly = true) + public Page findSimpleTravelogues(List tagFilter, Pageable pageable) { + Page travelogues = travelogueService.findAllByFilter(tagFilter, pageable); + return travelogues.map(this::getTravelogueSimpleResponse); + } + + @Transactional(readOnly = true) + public Page findSimpleTravelogues(Pageable pageable, TravelogueSearchRequest request) { + Page travelogues = travelogueService.findByKeyword(request.keyword(), pageable); + + return travelogues.map(this::getTravelogueSimpleResponse); + } + + private TravelogueSimpleResponse getTravelogueSimpleResponse(Travelogue travelogue) { + List tagResponses = travelogueTagService.readTagByTravelogue(travelogue); + TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogue(travelogue); + return TravelogueSimpleResponse.of(travelogue, tagResponses, likeResponse); + } + + @Transactional + public TravelogueResponse updateTravelogue(Long id, MemberAuth member, TravelogueRequest request) { + Member author = memberService.getById(member.memberId()); + Travelogue travelogue = travelogueService.getTravelogueById(id); + + Travelogue updatedTravelogue = travelogueService.update(id, author, request); + List tags = travelogueTagService.updateTravelogueTags(travelogue, request.tags()); + TravelogueLikeResponse like = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, author); + + clearTravelogueContents(travelogue); + + return TravelogueResponse.of(updatedTravelogue, createDays(request.days(), updatedTravelogue), tags, like); + } + + private void clearTravelogueContents(Travelogue travelogue) { + traveloguePhotoService.deleteAllByTravelogue(travelogue); + traveloguePlaceService.deleteAllByTravelogue(travelogue); + travelogueDayService.deleteAllByTravelogue(travelogue); + } + + @Transactional + public void deleteTravelogueById(Long id, MemberAuth member) { + Member author = memberService.getById(member.memberId()); + Travelogue travelogue = travelogueService.getTravelogueById(id); + travelogueService.validateAuthor(travelogue, author); + + clearTravelogueContents(travelogue); + travelogueService.delete(travelogue, author); + } + + @Transactional + public TravelogueLikeResponse unlikeTravelogue(Long travelogueId, MemberAuth member) { + Travelogue travelogue = travelogueService.getTravelogueById(travelogueId); + Member liker = memberService.getById(member.memberId()); + + return travelogueLikeService.unlikeTravelogue(travelogue, liker); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java new file mode 100644 index 00000000..579435fb --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java @@ -0,0 +1,49 @@ +package kr.touroot.travelogue.service; + +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueLike; +import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; +import kr.touroot.travelogue.repository.TravelogueLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TravelogueLikeService { + + private final TravelogueLikeRepository travelogueLikeRepository; + + @Transactional(readOnly = true) + public TravelogueLikeResponse findLikeByTravelogue(Travelogue travelogue) { + return new TravelogueLikeResponse(false, travelogueLikeRepository.countByTravelogue(travelogue)); + } + + @Transactional(readOnly = true) + public TravelogueLikeResponse findLikeByTravelogueAndLiker(Travelogue travelogue, Member liker) { + boolean exists = travelogueLikeRepository.existsByTravelogueAndLiker(travelogue, liker); + return new TravelogueLikeResponse(exists, travelogueLikeRepository.countByTravelogue(travelogue)); + } + + @Transactional + public TravelogueLikeResponse likeTravelogue(Travelogue travelogue, Member liker) { + boolean notExists = !travelogueLikeRepository.existsByTravelogueAndLiker(travelogue, liker); + if (notExists) { + TravelogueLike travelogueLike = new TravelogueLike(travelogue, liker); + travelogueLikeRepository.save(travelogueLike); + } + + return new TravelogueLikeResponse(true, travelogueLikeRepository.countByTravelogue(travelogue)); + } + + @Transactional + public TravelogueLikeResponse unlikeTravelogue(Travelogue travelogue, Member liker) { + boolean exists = travelogueLikeRepository.existsByTravelogueAndLiker(travelogue, liker); + if (exists) { + travelogueLikeRepository.deleteByTravelogueAndLiker(travelogue, liker); + } + + return new TravelogueLikeResponse(false, travelogueLikeRepository.countByTravelogue(travelogue)); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TraveloguePhotoService.java b/backend/src/main/java/kr/touroot/travelogue/service/TraveloguePhotoService.java new file mode 100644 index 00000000..1ac3d17d --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TraveloguePhotoService.java @@ -0,0 +1,51 @@ +package kr.touroot.travelogue.service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import kr.touroot.image.infrastructure.AwsS3Provider; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TraveloguePhoto; +import kr.touroot.travelogue.domain.TraveloguePlace; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.repository.TraveloguePhotoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TraveloguePhotoService { + + private final TraveloguePhotoRepository traveloguePhotoRepository; + private final AwsS3Provider s3Provider; + + @Transactional + public List createPhotos(List requests, TraveloguePlace place) { + List photos = new ArrayList<>(); + + for (int i = 0; i < requests.size(); i++) { + TraveloguePhotoRequest request = requests.get(i); + String url = s3Provider.copyImageToPermanentStorage(request.url()); + TraveloguePhoto photo = new TraveloguePhoto(i, url, place); + photos.add(traveloguePhotoRepository.save(photo)); + } + + return photos; + } + + @Transactional(readOnly = true) + public List findPhotoUrlsByPlace(TraveloguePlace traveloguePlace) { + List photos = traveloguePhotoRepository.findByTraveloguePlace(traveloguePlace); + + return photos.stream() + .sorted(Comparator.comparing(TraveloguePhoto::getOrder)) + .map(TraveloguePhoto::getKey) + .toList(); + } + + @Transactional + public void deleteAllByTravelogue(Travelogue travelogue) { + traveloguePhotoRepository.deleteAllByTraveloguePlaceTravelogueDayTravelogue(travelogue); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TraveloguePlaceService.java b/backend/src/main/java/kr/touroot/travelogue/service/TraveloguePlaceService.java new file mode 100644 index 00000000..ca7f2951 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TraveloguePlaceService.java @@ -0,0 +1,67 @@ +package kr.touroot.travelogue.service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.place.domain.Place; +import kr.touroot.place.repository.PlaceRepository; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePlace; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.repository.TraveloguePlaceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TraveloguePlaceService { + + private final PlaceRepository placeRepository; + private final TraveloguePlaceRepository traveloguePlaceRepository; + + @Transactional + public Map> createPlaces( + List requests, + TravelogueDay day + ) { + Map> places = new LinkedHashMap<>(); + + for (int i = 0; i < requests.size(); i++) { + TraveloguePlaceRequest request = requests.get(i); + Place place = getPlace(request); + + TraveloguePlace traveloguePlace = request.toTraveloguePlace(i, place, day); + places.put(traveloguePlaceRepository.save(traveloguePlace), request.photoUrls()); + } + + return places; + } + + private Place getPlace(TraveloguePlaceRequest request) { + return placeRepository.findByNameAndLatitudeAndLongitude( + request.placeName(), + request.position().lat(), + request.position().lng() + ).orElseGet(() -> placeRepository.save(request.toPlace())); + } + + @Transactional(readOnly = true) + public List findTraveloguePlacesByDay(TravelogueDay travelogueDay) { + return traveloguePlaceRepository.findByTravelogueDay(travelogueDay); + } + + @Transactional(readOnly = true) + public TraveloguePlace findTraveloguePlaceById(Long id) { + return traveloguePlaceRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행기 장소입니다.")); + } + + @Transactional + public void deleteAllByTravelogue(Travelogue travelogue) { + traveloguePlaceRepository.deleteAllByTravelogueDayTravelogue(travelogue); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java new file mode 100644 index 00000000..1239b523 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java @@ -0,0 +1,81 @@ +package kr.touroot.travelogue.service; + +import java.util.List; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.image.infrastructure.AwsS3Provider; +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.repository.query.TravelogueQueryRepository; +import kr.touroot.travelogue.repository.TravelogueRepository; +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; + +@RequiredArgsConstructor +@Service +public class TravelogueService { + + private final TravelogueRepository travelogueRepository; + private final AwsS3Provider s3Provider; + private final TravelogueQueryRepository travelogueQueryRepository; + + @Transactional + public Travelogue createTravelogue(Member author, TravelogueRequest request) { + String url = s3Provider.copyImageToPermanentStorage(request.thumbnail()); + Travelogue travelogue = request.toTravelogueOf(author, url); + return travelogueRepository.save(travelogue); + } + + @Transactional(readOnly = true) + public Travelogue getTravelogueById(Long id) { + return travelogueRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행기입니다.")); + } + + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return travelogueRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Page findAllByMember(Member member, Pageable pageable) { + return travelogueRepository.findAllByAuthor(member, pageable); + } + + @Transactional(readOnly = true) + public Page findByKeyword(String keyword, Pageable pageable) { + return travelogueQueryRepository.findByTitleContaining(keyword, pageable); + } + + @Transactional(readOnly = true) + public Page findAllByFilter(List filter, Pageable pageable) { + return travelogueQueryRepository.findAllByTag(filter, pageable); + } + + @Transactional + public Travelogue update(Long id, Member author, TravelogueRequest request) { + Travelogue travelogue = travelogueRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행기입니다.")); + validateAuthor(travelogue, author); + + travelogue.update(request.title(), request.thumbnail()); + + return travelogueRepository.save(travelogue); + } + + @Transactional + public void delete(Travelogue travelogue, Member author) { + validateAuthor(travelogue, author); + travelogueRepository.delete(travelogue); + } + + public void validateAuthor(Travelogue travelogue, Member author) { + if (!travelogue.isAuthor(author)) { + throw new ForbiddenException("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다."); + } + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueTagService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueTagService.java new file mode 100644 index 00000000..cc07d0bd --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueTagService.java @@ -0,0 +1,57 @@ +package kr.touroot.travelogue.service; + +import java.util.List; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.tag.domain.Tag; +import kr.touroot.tag.dto.TagResponse; +import kr.touroot.tag.repository.TagRepository; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueTag; +import kr.touroot.travelogue.repository.TravelogueTagRepository; +import kr.touroot.travelogue.repository.query.TravelogueTagQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TravelogueTagService { + + private final TagRepository tagRepository; + private final TravelogueTagRepository travelogueTagRepository; + private final TravelogueTagQueryRepository travelogueTagQueryRepository; + + @Transactional + public List createTravelogueTags(Travelogue travelogue, List tagIds) { + return tagIds.stream() + .map(id -> { + Tag tag = getTagById(id); + travelogueTagRepository.save(new TravelogueTag(travelogue, tag)); + return TagResponse.from(tag); + }).toList(); + } + + private Tag getTagById(Long id) { + return tagRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 태그입니다.")); + } + + @Transactional(readOnly = true) + public List readTagByTravelogue(Travelogue travelogue) { + return travelogueTagRepository.findAllByTravelogue(travelogue).stream() + .map(travelogueTag -> TagResponse.from(travelogueTag.getTag())) + .toList(); + } + + @Transactional(readOnly = true) + public List updateTravelogueTags(Travelogue travelogue, List tagIds) { + travelogueTagQueryRepository.deleteAllByTravelogue(travelogue); + + return tagIds.stream() + .map(id -> { + Tag tag = getTagById(id); + travelogueTagRepository.save(new TravelogueTag(travelogue, tag)); + return TagResponse.from(tag); + }).toList(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/controller/PlaceTodoController.java b/backend/src/main/java/kr/touroot/travelplan/controller/PlaceTodoController.java new file mode 100644 index 00000000..be894188 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/controller/PlaceTodoController.java @@ -0,0 +1,57 @@ +package kr.touroot.travelplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.travelplan.dto.request.TodoStatusUpdateRequest; +import kr.touroot.travelplan.dto.response.PlanPlaceTodoResponse; +import kr.touroot.travelplan.service.PlaceTodoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "여행 계획 장소의 TODO") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/todos") +public class PlaceTodoController { + + private final PlaceTodoService placeTodoService; + + @Operation(summary = "TODO 업데이트") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 TODO ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "작성자가 아닌 사용자가 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PatchMapping("/{id}") + public ResponseEntity updateTodo( + @PathVariable Long id, + @Valid @RequestBody TodoStatusUpdateRequest updateRequest, + MemberAuth memberAuth + ) { + PlanPlaceTodoResponse updatedTodoResponse = placeTodoService.updateTodoStatus(id, memberAuth, updateRequest); + return ResponseEntity.ok(updatedTodoResponse); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/controller/TravelPlanController.java b/backend/src/main/java/kr/touroot/travelplan/controller/TravelPlanController.java new file mode 100644 index 00000000..8d9bc59a --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/controller/TravelPlanController.java @@ -0,0 +1,160 @@ +package kr.touroot.travelplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import java.util.UUID; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.travelplan.dto.request.PlanRequest; +import kr.touroot.travelplan.dto.response.PlanCreateResponse; +import kr.touroot.travelplan.dto.response.PlanResponse; +import kr.touroot.travelplan.service.TravelPlanService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "여행 계획") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/travel-plans") +public class TravelPlanController { + + private final TravelPlanService travelPlanService; + + @Operation(summary = "여행 계획 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "여행 계획 생성이 정상적으로 성공했을 때" + ), + @ApiResponse( + responseCode = "400", + description = "Body에 유효하지 않은 값이 존재하거나 지난 날짜에 대한 계획을 생성할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 생성을 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PostMapping + public ResponseEntity createTravelPlan( + @Valid @RequestBody PlanRequest request, + MemberAuth memberAuth + ) { + PlanCreateResponse data = travelPlanService.createTravelPlan(request, memberAuth); + return ResponseEntity.created(URI.create("/api/v1/travel-plans/" + data.id())) + .body(data); + } + + @Operation(summary = "여행 계획 상세 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "여행 계획 상세 조회가 정상적으로 성공했을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않은 여행 계획을 조회할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "작성자가 아닌 사용자가 조회를 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @GetMapping("/{id}") + public ResponseEntity readTravelPlan( + @Parameter(description = "여행 계획 id") @PathVariable Long id, + MemberAuth memberAuth + ) { + PlanResponse data = travelPlanService.readTravelPlan(id, memberAuth); + return ResponseEntity.ok(data); + } + + @Operation(summary = "여행 계획 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "요청 Body에 올바르지 않은 값이 전달되었을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "작성자가 아닌 사용자가 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PutMapping("/{id}") + public ResponseEntity updateTravelPlan( + @PathVariable Long id, + @Valid MemberAuth memberAuth, + @Valid @RequestBody PlanRequest request + ) { + return ResponseEntity.ok(travelPlanService.updateTravelPlan(id, memberAuth, request)); + } + + @Operation(summary = "여행 계획 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "요청이 정상적으로 처리되었을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않는 여행 계획 ID로 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "작성자가 아닌 사용자가 요청했을 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @DeleteMapping("/{id}") + public ResponseEntity deleteTravelPlan(@PathVariable Long id, MemberAuth memberAuth) { + travelPlanService.deleteByTravelPlanId(id, memberAuth); + return ResponseEntity.noContent() + .build(); + } + + @Operation(summary = "공유된 여행 계획 상세 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "여행 계획 상세 조회가 정상적으로 성공했을 때" + ), + @ApiResponse( + responseCode = "400", + description = "존재하지 않은 여행 계획을 조회할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + }) + @GetMapping("shared/{shareKey}") + public ResponseEntity readSharedTravelPlan( + @Parameter(description = "여행 계획 공유 키") @PathVariable UUID shareKey + ) { + PlanResponse data = travelPlanService.readTravelPlan(shareKey); + return ResponseEntity.ok(data); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlaceTodo.java b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlaceTodo.java new file mode 100644 index 00000000..703e39be --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlaceTodo.java @@ -0,0 +1,96 @@ +package kr.touroot.travelplan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travel_place_todo SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TravelPlaceTodo extends BaseEntity { + + private static final int CONTENT_MIN_LENGTH = 1; + private static final int CONTENT_MAX_LENGTH = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlanPlace travelPlanPlace; + + @Column(nullable = false) + private String content; + + @Column(name = "TODO_ORDER", nullable = false) + private Integer order; + + @Column(nullable = false) + private Boolean isChecked; + + private TravelPlaceTodo(Long id, TravelPlanPlace travelPlanPlace, String content, Integer order, + Boolean isChecked) { + validate(travelPlanPlace, content, order, isChecked); + this.id = id; + this.travelPlanPlace = travelPlanPlace; + this.content = content; + this.order = order; + this.isChecked = isChecked; + } + + public TravelPlaceTodo(TravelPlanPlace travelPlanPlace, String content, Integer order, Boolean isChecked) { + this(null, travelPlanPlace, content, order, isChecked); + } + + private void validate(TravelPlanPlace travelPlanPlace, String content, Integer order, Boolean isChecked) { + validateNotNull(travelPlanPlace, content, order, isChecked); + validateNotBlank(content); + validateContentLength(content); + validateOrderNonNegative(order); + } + + private void validateNotNull(TravelPlanPlace travelPlanPlace, String content, Integer order, Boolean isChecked) { + if (travelPlanPlace == null || content == null || order == null || isChecked == null) { + throw new BadRequestException("여행 계획 장소에 대한 TODO에서 장소와 내용, 순서 그리고 달성 여부는 비어 있을 수 없습니다"); + } + } + + private void validateNotBlank(String content) { + if (content.isBlank()) { + throw new BadRequestException("TODO 내용은 빈 문자열로만 이루어질 수 없습니다"); + } + } + + private void validateContentLength(String content) { + if (CONTENT_MIN_LENGTH > content.length() || content.length() > CONTENT_MAX_LENGTH) { + throw new BadRequestException( + "TODO 내용의 길이는 " + CONTENT_MIN_LENGTH + "자 이상, " + CONTENT_MAX_LENGTH + "자 이하여야 합니다" + ); + } + } + + private void validateOrderNonNegative(Integer order) { + if (order < 0) { + throw new BadRequestException("TODO 순서는 음수일 수 없습니다"); + } + } + + public void updateCheckedStatus(boolean checkedStatus) { + isChecked = checkedStatus; + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlan.java b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlan.java new file mode 100644 index 00000000..85d7e050 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlan.java @@ -0,0 +1,98 @@ +package kr.touroot.travelplan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; +import java.util.UUID; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travel_plan SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TravelPlan extends BaseEntity { + + private static final int TITLE_MIN_LENGTH = 1; + private static final int TITLE_MAX_LENGTH = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private UUID shareKey; + + @JoinColumn(name = "author_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Member author; + + public TravelPlan(Long id, String title, LocalDate startDate, UUID shareKey, Member author) { + validate(title, startDate, author, shareKey); + this.id = id; + this.title = title; + this.startDate = startDate; + this.shareKey = shareKey; + this.author = author; + } + + public TravelPlan(String title, LocalDate startDate, UUID shareKey, Member author) { + this(null, title, startDate, shareKey, author); + } + + private void validate(String title, LocalDate startDate, Member author, UUID shareKey) { + validateNotNull(title, startDate, author, shareKey); + validateNotBlank(title); + validateTitleLength(title); + } + + private void validateNotNull(String title, LocalDate startDate, Member author, UUID shareKey) { + if (title == null || startDate == null || author == null || shareKey == null) { + throw new BadRequestException("여행 계획에서 제목과 시작 날짜, 공유 키, 그리고 작성자는 비어 있을 수 없습니다"); + } + } + + private void validateNotBlank(String title) { + if (title.isBlank()) { + throw new BadRequestException("여행 계획에서 제목은 공백 문자로만 이루어질 수 없습니다"); + } + } + + private void validateTitleLength(String title) { + if (TITLE_MIN_LENGTH > title.length() || title.length() > TITLE_MAX_LENGTH) { + throw new BadRequestException("여행 계획은 " + TITLE_MIN_LENGTH + "자 이상, " + TITLE_MAX_LENGTH + "자 이하여야 합니다"); + } + } + + public void update(String title, LocalDate startDate) { + this.title = title; + this.startDate = startDate; + } + + public boolean isStartDateBefore(LocalDate date) { + return startDate.isBefore(date); + } + + public boolean isAuthor(Member member) { + return member.equals(author); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlanDay.java b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlanDay.java new file mode 100644 index 00000000..7b69924c --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlanDay.java @@ -0,0 +1,70 @@ +package kr.touroot.travelplan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travel_plan_day SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TravelPlanDay extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "PLAN_DAY_ORDER", nullable = false) + Integer order; + + @JoinColumn(name = "PLAN_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlan plan; + + private TravelPlanDay(Long id, Integer order, TravelPlan plan) { + validate(order, plan); + this.id = id; + this.order = order; + this.plan = plan; + } + + public TravelPlanDay(Integer order, TravelPlan plan) { + this(null, order, plan); + } + + private void validate(Integer order, TravelPlan plan) { + validateNotNull(order, plan); + validateOrderRange(order); + } + + private void validateNotNull(Integer order, TravelPlan plan) { + if (order == null || plan == null) { + throw new BadRequestException("여행 계획 날짜에서 순서와 속하고 있는 여행 계획은 비어 있을 수 없습니다"); + } + } + + private void validateOrderRange(Integer order) { + if (order < 0) { + throw new BadRequestException("여행 계획 날짜 순서는 음수일 수 없습니다"); + } + } + + public LocalDate getCurrentDate() { + LocalDate startDate = plan.getStartDate(); + return startDate.plusDays(order); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlanPlace.java b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlanPlace.java new file mode 100644 index 00000000..1512036c --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/domain/TravelPlanPlace.java @@ -0,0 +1,70 @@ +package kr.touroot.travelplan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.touroot.global.entity.BaseEntity; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.place.domain.Place; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE travel_plan_place SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Entity +public class TravelPlanPlace extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "PLAN_PLACE_ORDER", nullable = false) + private Integer order; + + @JoinColumn(name = "PLAN_DAY_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlanDay day; + + @JoinColumn(name = "PLACE_ID", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Place place; + + private TravelPlanPlace(Long id, Integer order, TravelPlanDay day, Place place) { + validate(order, day, place); + this.id = id; + this.order = order; + this.day = day; + this.place = place; + } + + public TravelPlanPlace(Integer order, TravelPlanDay day, Place place) { + this(null, order, day, place); + } + + private void validate(Integer order, TravelPlanDay day, Place place) { + validateNotNull(order, day, place); + validateOrderRange(order); + } + + private void validateNotNull(Integer order, TravelPlanDay day, Place place) { + if (order == null || day == null || place == null) { + throw new BadRequestException("여행 계획 장소에서 순서와 날짜, 그리고 장소 상세는 비어 있을 수 없습니다"); + } + } + + private void validateOrderRange(Integer order) { + if (order < 0) { + throw new BadRequestException("장소의 방문 순서는 음수일 수 없습니다"); + } + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanDayRequest.java b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanDayRequest.java new file mode 100644 index 00000000..f2c83802 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanDayRequest.java @@ -0,0 +1,22 @@ +package kr.touroot.travelplan.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanDay; + +public record PlanDayRequest( + @Schema(description = "여행 장소 정보") + @Valid + @Size(min = 1, message = "여행 장소는 한 개 이상이어야 합니다.") + @NotNull(message = "여행 장소 정보는 비어있을 수 없습니다.") + List places +) { + + public TravelPlanDay toPlanDay(int order, TravelPlan plan) { + return new TravelPlanDay(order, plan); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPlaceRequest.java b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPlaceRequest.java new file mode 100644 index 00000000..ff12a31f --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPlaceRequest.java @@ -0,0 +1,32 @@ +package kr.touroot.travelplan.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import kr.touroot.place.domain.Place; +import kr.touroot.travelplan.domain.TravelPlanDay; +import kr.touroot.travelplan.domain.TravelPlanPlace; +import lombok.Builder; + +@Builder +public record PlanPlaceRequest( + @Schema(description = "여행 장소 이름", example = "잠실한강공원") + @NotBlank(message = "장소명은 비어있을 수 없습니다.") String placeName, + @Valid + @NotNull(message = "위치는 비어있을 수 없습니다.") + PlanPositionRequest position, + @Valid + @NotNull(message = "TODO 리스트는 필수 입니다.") + List todos +) { + + public TravelPlanPlace toPlanPlace(int order, TravelPlanDay day, Place place) { + return new TravelPlanPlace(order, day, place); + } + + public Place toPlace() { + return new Place(placeName, position.lat(), position.lng()); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPlaceTodoRequest.java b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPlaceTodoRequest.java new file mode 100644 index 00000000..7664988d --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPlaceTodoRequest.java @@ -0,0 +1,23 @@ +package kr.touroot.travelplan.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import kr.touroot.travelplan.domain.TravelPlanPlace; + +public record PlanPlaceTodoRequest( + @Schema(description = "여행 장소에서 진행할 TODO", example = "함덕 해수욕장 산책") + @NotBlank(message = "TODO 내용은 비어 있을 수 없습니다") + @Size(min = 1, max = 20, message = "TODO 내용은 1자에서 20자 사이의 길이를 가져야 합니다") + String content, + @Schema(description = "TODO의 체크 여부", example = "true") + @NotNull(message = "TODO의 체크 여부는 비어 있을 수 없습니다.") + Boolean isChecked +) { + + public TravelPlaceTodo toPlaceTodo(TravelPlanPlace travelPlanPlace, Integer order) { + return new TravelPlaceTodo(travelPlanPlace, content, order, isChecked); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPositionRequest.java b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPositionRequest.java new file mode 100644 index 00000000..3303b8b8 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanPositionRequest.java @@ -0,0 +1,14 @@ +package kr.touroot.travelplan.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record PlanPositionRequest( + @Schema(description = "여행 장소 위도", example = "37.5175896") + @NotNull(message = "위도는 비어있을 수 없습니다.") + String lat, + @Schema(description = "여행 장소 경도", example = "127.0867236") + @NotNull(message = "경도는 비어있을 수 없습니다.") + String lng +) { +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanRequest.java b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanRequest.java new file mode 100644 index 00000000..9cd1d56e --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/request/PlanRequest.java @@ -0,0 +1,33 @@ +package kr.touroot.travelplan.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlan; +import lombok.Builder; + +@Builder +public record PlanRequest( + @Schema(description = "여행 계획 제목", example = "신나는 잠실 한강 여행") + @NotBlank(message = "여행 계획 제목은 비어있을 수 없습니다.") + String title, + @Schema(description = "여행 계획 시작일", example = "2024-11-16") + @NotNull(message = "시작일은 비어있을 수 없습니다.") + LocalDate startDate, + @Schema(description = "여행 날짜 정보") + @Valid + @Size(min = 1, message = "여행 날짜는 하루 이상 있어야 합니다.") + @NotNull(message = "여행 날짜 정보는 비어있을 수 없습니다.") + List days +) { + + public TravelPlan toTravelPlan(Member author, UUID shareKey) { + return new TravelPlan(title, startDate, shareKey, author); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/request/TodoStatusUpdateRequest.java b/backend/src/main/java/kr/touroot/travelplan/dto/request/TodoStatusUpdateRequest.java new file mode 100644 index 00000000..c30eb4a7 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/request/TodoStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package kr.touroot.travelplan.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record TodoStatusUpdateRequest( + @Schema(description = "업데이트 하고자 하는 체크 상태", example = "true") + @NotNull + Boolean checked +) { +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanCreateResponse.java b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanCreateResponse.java new file mode 100644 index 00000000..f742bb72 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanCreateResponse.java @@ -0,0 +1,9 @@ +package kr.touroot.travelplan.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PlanCreateResponse( + @Schema(description = "생성된 여행 계획 id", example = "1") + Long id +) { +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanDayResponse.java b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanDayResponse.java new file mode 100644 index 00000000..1b90a266 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanDayResponse.java @@ -0,0 +1,26 @@ +package kr.touroot.travelplan.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; +import kr.touroot.travelplan.domain.TravelPlanDay; +import lombok.Builder; + +@Builder +public record PlanDayResponse( + @Schema(description = "여행 날짜 Id") Long id, + @Schema(description = "여행 일자") LocalDate date, + @Schema(description = "여행 장소별 정보") List places +) { + + public static PlanDayResponse of( + TravelPlanDay planDay, + List places + ) { + return PlanDayResponse.builder() + .id(planDay.getId()) + .date(planDay.getCurrentDate()) + .places(places) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPlaceResponse.java b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPlaceResponse.java new file mode 100644 index 00000000..f36dfd8a --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPlaceResponse.java @@ -0,0 +1,28 @@ +package kr.touroot.travelplan.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import kr.touroot.place.domain.Place; +import kr.touroot.travelplan.domain.TravelPlanPlace; +import lombok.Builder; + +@Builder +public record PlanPlaceResponse( + @Schema(description = "여행 장소 Id", example = "1") Long id, + @Schema(description = "여행 장소 이름", example = "잠실한강공원") String placeName, + @Schema(description = "여행 장소 위치") PlanPositionResponse position, + @Schema(description = "여행 장소 TODO") List todos +) { + + public static PlanPlaceResponse of(TravelPlanPlace planPlace, List todos) { + Place place = planPlace.getPlace(); + PlanPositionResponse locationResponse = PlanPositionResponse.from(place); + + return PlanPlaceResponse.builder() + .id(planPlace.getId()) + .placeName(place.getName()) + .position(locationResponse) + .todos(todos) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPlaceTodoResponse.java b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPlaceTodoResponse.java new file mode 100644 index 00000000..95040ff2 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPlaceTodoResponse.java @@ -0,0 +1,23 @@ +package kr.touroot.travelplan.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import lombok.Builder; + +@Builder +public record PlanPlaceTodoResponse( + @Schema(description = "TODO 아이디") Long id, + @Schema(description = "TODO 내용") String content, + @Schema(description = "TODO 순서") Integer order, + @Schema(description = "TODO 체크 여부") Boolean checked +) { + + public static PlanPlaceTodoResponse from(TravelPlaceTodo travelPlaceTodo) { + return PlanPlaceTodoResponse.builder(). + id(travelPlaceTodo.getId()) + .content(travelPlaceTodo.getContent()) + .order(travelPlaceTodo.getOrder()) + .checked(travelPlaceTodo.getIsChecked()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPositionResponse.java b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPositionResponse.java new file mode 100644 index 00000000..e765d569 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanPositionResponse.java @@ -0,0 +1,21 @@ +package kr.touroot.travelplan.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.touroot.place.domain.Place; +import lombok.Builder; + +@Builder +public record PlanPositionResponse( + @Schema(description = "여행 획 장소 상세 Id", example = "1") Long id, + @Schema(description = "여행 장소 위도", example = "37.5175896") String lat, + @Schema(description = "여행 계획 경도", example = "127.0867236") String lng +) { + + public static PlanPositionResponse from(Place place) { + return PlanPositionResponse.builder() + .id(place.getId()) + .lat(place.getLatitude()) + .lng(place.getLongitude()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanResponse.java b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanResponse.java new file mode 100644 index 00000000..b3f9937a --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/dto/response/PlanResponse.java @@ -0,0 +1,28 @@ +package kr.touroot.travelplan.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import kr.touroot.travelplan.domain.TravelPlan; +import lombok.Builder; + +@Builder +public record PlanResponse( + @Schema(description = "여행 계획 id", example = "1") Long id, + @Schema(description = "여행 계획 제목", example = "신나는 잠실 한강 여행") String title, + @Schema(description = "여행 시작일", example = "2024-11-16") LocalDate startDate, + @Schema(description = "여행 계획 날짜별 정보") List days, + @Schema(description = "여행 계획 공유 share Key") UUID shareKey +) { + + public static PlanResponse of(TravelPlan travelPlan, List days) { + return PlanResponse.builder() + .id(travelPlan.getId()) + .title(travelPlan.getTitle()) + .startDate(travelPlan.getStartDate()) + .days(days) + .shareKey(travelPlan.getShareKey()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoQueryRepository.java b/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoQueryRepository.java new file mode 100644 index 00000000..6b689c5f --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoQueryRepository.java @@ -0,0 +1,11 @@ +package kr.touroot.travelplan.repository; + +import java.util.Optional; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import org.springframework.data.repository.query.Param; + +public interface PlaceTodoQueryRepository { + + Optional findOwnerOf(@Param("placeTodo") TravelPlaceTodo placeTodo); +} diff --git a/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoQueryRepositoryImpl.java b/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoQueryRepositoryImpl.java new file mode 100644 index 00000000..c6df82f9 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoQueryRepositoryImpl.java @@ -0,0 +1,35 @@ +package kr.touroot.travelplan.repository; + +import static kr.touroot.member.domain.QMember.member; +import static kr.touroot.travelplan.domain.QTravelPlaceTodo.travelPlaceTodo; +import static kr.touroot.travelplan.domain.QTravelPlan.travelPlan; +import static kr.touroot.travelplan.domain.QTravelPlanDay.travelPlanDay; +import static kr.touroot.travelplan.domain.QTravelPlanPlace.travelPlanPlace; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Optional; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class PlaceTodoQueryRepositoryImpl implements PlaceTodoQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Optional findOwnerOf(TravelPlaceTodo placeTodo) { + return Optional.ofNullable( + jpaQueryFactory.select(member) + .from(member) + .innerJoin(travelPlan).on(travelPlan.author.eq(member)) + .innerJoin(travelPlanDay).on(travelPlanDay.plan.eq(travelPlan)) + .innerJoin(travelPlanPlace).on(travelPlanPlace.day.eq(travelPlanDay)) + .innerJoin(travelPlaceTodo).on(travelPlaceTodo.travelPlanPlace.eq(travelPlanPlace)) + .where(travelPlaceTodo.eq(placeTodo)) + .fetchOne() + ); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoRepository.java b/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoRepository.java new file mode 100644 index 00000000..21467827 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/repository/PlaceTodoRepository.java @@ -0,0 +1,14 @@ +package kr.touroot.travelplan.repository; + +import java.util.List; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanPlace; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PlaceTodoRepository extends JpaRepository { + + List findByTravelPlanPlace(TravelPlanPlace travelPlanPlace); + + void deleteByTravelPlanPlaceDayPlan(TravelPlan travelPlan); +} diff --git a/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanDayRepository.java b/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanDayRepository.java new file mode 100644 index 00000000..d91444af --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanDayRepository.java @@ -0,0 +1,13 @@ +package kr.touroot.travelplan.repository; + +import java.util.List; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanDay; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelPlanDayRepository extends JpaRepository { + + List findByPlan(TravelPlan travelPlan); + + void deleteByPlan(TravelPlan plan); +} diff --git a/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanPlaceRepository.java b/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanPlaceRepository.java new file mode 100644 index 00000000..36f07fc9 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanPlaceRepository.java @@ -0,0 +1,14 @@ +package kr.touroot.travelplan.repository; + +import java.util.List; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanDay; +import kr.touroot.travelplan.domain.TravelPlanPlace; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelPlanPlaceRepository extends JpaRepository { + + List findByDay(TravelPlanDay day); + + void deleteByDayPlan(TravelPlan plan); +} diff --git a/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanRepository.java b/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanRepository.java new file mode 100644 index 00000000..51d41d1b --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/repository/TravelPlanRepository.java @@ -0,0 +1,16 @@ +package kr.touroot.travelplan.repository; + +import java.util.Optional; +import java.util.UUID; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlan; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelPlanRepository extends JpaRepository { + + Page findAllByAuthor(Member member, Pageable pageable); + + Optional findByShareKey(UUID shareKey); +} diff --git a/backend/src/main/java/kr/touroot/travelplan/service/PlaceTodoService.java b/backend/src/main/java/kr/touroot/travelplan/service/PlaceTodoService.java new file mode 100644 index 00000000..8bdbd406 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/service/PlaceTodoService.java @@ -0,0 +1,45 @@ +package kr.touroot.travelplan.service; + +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.member.domain.Member; +import kr.touroot.member.repository.MemberRepository; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import kr.touroot.travelplan.dto.request.TodoStatusUpdateRequest; +import kr.touroot.travelplan.dto.response.PlanPlaceTodoResponse; +import kr.touroot.travelplan.repository.PlaceTodoQueryRepository; +import kr.touroot.travelplan.repository.PlaceTodoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class PlaceTodoService { + + private final PlaceTodoRepository placeTodoRepository; + private final PlaceTodoQueryRepository placeTodoQueryRepository; + private final MemberRepository memberRepository; + + @Transactional + public PlanPlaceTodoResponse updateTodoStatus( + Long id, + MemberAuth memberAuth, + TodoStatusUpdateRequest updateRequest + ) { + Member accessor = memberRepository.findById(memberAuth.memberId()) + .orElseThrow(() -> new BadRequestException("존재하지 않는 사용자입니다.")); + TravelPlaceTodo todo = placeTodoRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 TODO 입니다")); + Member owner = placeTodoQueryRepository.findOwnerOf(todo) + .orElseThrow(() -> new BadRequestException("TODO 작성자가 존재하지 않습니다")); + + if (!owner.equals(accessor)) { + throw new ForbiddenException("TODO 체크는 작성자만 가능합니다"); + } + + todo.updateCheckedStatus(updateRequest.checked()); + return PlanPlaceTodoResponse.from(todo); + } +} diff --git a/backend/src/main/java/kr/touroot/travelplan/service/TravelPlanService.java b/backend/src/main/java/kr/touroot/travelplan/service/TravelPlanService.java new file mode 100644 index 00000000..99e139ad --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelplan/service/TravelPlanService.java @@ -0,0 +1,222 @@ +package kr.touroot.travelplan.service; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.member.domain.Member; +import kr.touroot.member.repository.MemberRepository; +import kr.touroot.place.domain.Place; +import kr.touroot.place.repository.PlaceRepository; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanDay; +import kr.touroot.travelplan.domain.TravelPlanPlace; +import kr.touroot.travelplan.dto.request.PlanDayRequest; +import kr.touroot.travelplan.dto.request.PlanPlaceRequest; +import kr.touroot.travelplan.dto.request.PlanPlaceTodoRequest; +import kr.touroot.travelplan.dto.request.PlanRequest; +import kr.touroot.travelplan.dto.response.PlanCreateResponse; +import kr.touroot.travelplan.dto.response.PlanDayResponse; +import kr.touroot.travelplan.dto.response.PlanPlaceResponse; +import kr.touroot.travelplan.dto.response.PlanPlaceTodoResponse; +import kr.touroot.travelplan.dto.response.PlanResponse; +import kr.touroot.travelplan.repository.PlaceTodoRepository; +import kr.touroot.travelplan.repository.TravelPlanDayRepository; +import kr.touroot.travelplan.repository.TravelPlanPlaceRepository; +import kr.touroot.travelplan.repository.TravelPlanRepository; +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; + +@RequiredArgsConstructor +@Service +public class TravelPlanService { + + private final MemberRepository memberRepository; + private final TravelPlanRepository travelPlanRepository; + private final TravelPlanDayRepository travelPlanDayRepository; + private final TravelPlanPlaceRepository travelPlanPlaceRepository; + private final PlaceRepository placeRepository; + private final PlaceTodoRepository placeTodoRepository; + + @Transactional + public PlanCreateResponse createTravelPlan(PlanRequest request, MemberAuth memberAuth) { + Member author = getMemberByMemberAuth(memberAuth); + TravelPlan travelPlan = request.toTravelPlan(author, UUID.randomUUID()); + validateCreateTravelPlan(travelPlan); + + TravelPlan savedTravelPlan = travelPlanRepository.save(travelPlan); + createPlanDay(request.days(), savedTravelPlan); + + return new PlanCreateResponse(savedTravelPlan.getId()); + } + + private void validateCreateTravelPlan(TravelPlan travelPlan) { + if (travelPlan.isStartDateBefore(LocalDate.now())) { + throw new BadRequestException("지난 날짜에 대한 계획은 작성할 수 없습니다."); + } + } + + private Member getMemberByMemberAuth(MemberAuth memberAuth) { + return memberRepository.findById(memberAuth.memberId()) + .orElseThrow(() -> new BadRequestException("존재하지 않는 사용자입니다.")); + } + + private void createPlanDay(List request, TravelPlan savedTravelPlan) { + for (int order = 0; order < request.size(); order++) { + PlanDayRequest dayRequest = request.get(order); + TravelPlanDay travelPlanDay = travelPlanDayRepository.save(dayRequest.toPlanDay(order, savedTravelPlan)); + createPlanPlace(dayRequest.places(), travelPlanDay); + } + } + + private void createPlanPlace(List request, TravelPlanDay travelPlanDay) { + for (int order = 0; order < request.size(); order++) { + PlanPlaceRequest planRequest = request.get(order); + Place place = getPlace(planRequest); + TravelPlanPlace planPlace = planRequest.toPlanPlace(order, travelPlanDay, place); + TravelPlanPlace travelPlanPlace = travelPlanPlaceRepository.save(planPlace); + createPlaceTodo(planRequest.todos(), travelPlanPlace); + } + } + + private void createPlaceTodo(List request, TravelPlanPlace travelPlanPlace) { + for (int order = 0; order < request.size(); order++) { + PlanPlaceTodoRequest todoRequest = request.get(order); + TravelPlaceTodo travelPlaceTodo = todoRequest.toPlaceTodo(travelPlanPlace, order); + placeTodoRepository.save(travelPlaceTodo); + } + } + + private Place getPlace(PlanPlaceRequest planRequest) { + return placeRepository.findByNameAndLatitudeAndLongitude( + planRequest.placeName(), + planRequest.position().lat(), + planRequest.position().lng() + ).orElseGet(() -> placeRepository.save(planRequest.toPlace())); + } + + @Transactional(readOnly = true) + public PlanResponse readTravelPlan(Long planId, MemberAuth memberAuth) { + TravelPlan travelPlan = getTravelPlanById(planId); + Member member = getMemberByMemberAuth(memberAuth); + validateReadByAuthor(travelPlan, member); + + return PlanResponse.of(travelPlan, getTravelPlanDayResponses(travelPlan)); + } + + private void validateReadByAuthor(TravelPlan travelPlan, Member member) { + if (!travelPlan.isAuthor(member)) { + throw new ForbiddenException("여행 계획 조회는 작성자만 가능합니다."); + } + } + + @Transactional(readOnly = true) + public PlanResponse readTravelPlan(UUID shareKey) { + TravelPlan travelPlan = getTravelPlanByShareKey(shareKey); + + return PlanResponse.of(travelPlan, getTravelPlanDayResponses(travelPlan)); + } + + private TravelPlan getTravelPlanById(Long planId) { + return travelPlanRepository.findById(planId) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행 계획입니다.")); + } + + private TravelPlan getTravelPlanByShareKey(UUID shareKey) { + return travelPlanRepository.findByShareKey(shareKey) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행 계획입니다.")); + } + + public PlanResponse getTravelPlanResponse(TravelPlan travelPlan) { + return PlanResponse.of(travelPlan, getTravelPlanDayResponses(travelPlan)); + } + + private List getTravelPlanDayResponses(TravelPlan travelPlan) { + List planDays = travelPlanDayRepository.findByPlan(travelPlan); + + return planDays.stream() + .sorted(Comparator.comparing(TravelPlanDay::getOrder)) + .map(day -> PlanDayResponse.of(day, getTravelPlanPlaceResponses(day))) + .toList(); + } + + private List getTravelPlanPlaceResponses(TravelPlanDay day) { + List places = travelPlanPlaceRepository.findByDay(day); + + return places.stream() + .sorted(Comparator.comparing(TravelPlanPlace::getOrder)) + .map(place -> PlanPlaceResponse.of(place, getPlaceTodos(place))) + .toList(); + } + + private List getPlaceTodos(TravelPlanPlace place) { + return placeTodoRepository.findByTravelPlanPlace(place) + .stream() + .sorted(Comparator.comparing(TravelPlaceTodo::getOrder)) + .map(PlanPlaceTodoResponse::from) + .toList(); + } + + public Page getAllByAuthor(Member member, Pageable pageable) { + return travelPlanRepository.findAllByAuthor(member, pageable); + } + + public int calculateTravelPeriod(TravelPlan travelPlan) { + return travelPlanDayRepository.findByPlan(travelPlan) + .size(); + } + + @Transactional + public PlanCreateResponse updateTravelPlan(Long planId, MemberAuth memberAuth, PlanRequest request) { + TravelPlan travelPlan = getTravelPlanById(planId); + Member author = getMemberByMemberAuth(memberAuth); + validateUpdateByAuthor(travelPlan, author); + + clearTravelPlanContents(travelPlan); + updateTravelPlanContents(request, travelPlan); + return new PlanCreateResponse(travelPlan.getId()); + } + + private void validateUpdateByAuthor(TravelPlan travelPlan, Member member) { + if (!travelPlan.isAuthor(member)) { + throw new ForbiddenException("여행 계획 수정은 작성자만 가능합니다."); + } + } + + private void clearTravelPlanContents(TravelPlan travelPlan) { + placeTodoRepository.deleteByTravelPlanPlaceDayPlan(travelPlan); + travelPlanPlaceRepository.deleteByDayPlan(travelPlan); + travelPlanDayRepository.deleteByPlan(travelPlan); + } + + private void updateTravelPlanContents(PlanRequest request, TravelPlan travelPlan) { + travelPlan.update(request.title(), request.startDate()); + travelPlanRepository.save(travelPlan); + createPlanDay(request.days(), travelPlan); + } + + @Transactional + public void deleteByTravelPlanId(Long planId, MemberAuth memberAuth) { + TravelPlan travelPlan = getTravelPlanById(planId); + Member author = getMemberByMemberAuth(memberAuth); + validateDeleteByAuthor(travelPlan, author); + + placeTodoRepository.deleteByTravelPlanPlaceDayPlan(travelPlan); + travelPlanPlaceRepository.deleteByDayPlan(travelPlan); + travelPlanDayRepository.deleteByPlan(travelPlan); + travelPlanRepository.delete(travelPlan); + } + + private void validateDeleteByAuthor(TravelPlan travelPlan, Member member) { + if (!travelPlan.isAuthor(member)) { + throw new ForbiddenException("여행 계획 삭제는 작성자만 가능합니다."); + } + } +} diff --git a/backend/src/main/java/woowacourse/touroot/TourootApplication.java b/backend/src/main/java/woowacourse/touroot/TourootApplication.java deleted file mode 100644 index a3f26375..00000000 --- a/backend/src/main/java/woowacourse/touroot/TourootApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package woowacourse.touroot; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class TourootApplication { - - public static void main(String[] args) { - SpringApplication.run(TourootApplication.class, args); - } - -} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 17164e13..910665cb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,13 +1,133 @@ +oauth: + kakao: + user-information-request-uri: https://kapi.kakao.com/v2/user/me + access-token-request-uri: https://kauth.kakao.com/oauth/token + rest-api-key: ENC(i6J7NWUsDpXVbJrbSUcNFI3h0oc6v8PxuHShU9UA7EVuUNLtQN/ANII+8j5HjhGO) + +jasypt: + encryptor: + algorithm: PBEWithMD5AndDES + iv-generator-classname: org.jasypt.iv.NoIvGenerator + +cloud: + aws: + s3: + bucket: techcourse-project-2024 + image-base-uri: https://dev.touroot.kr/ + base-storage-path: touroot/ + temporary-storage-path: temporary/ + image-storage-path: images/ +--- +# local profile spring: + config: + activate: + on-profile: local + h2: + console: + enabled: true datasource: driver-class-name: org.h2.Driver - url: jdbc:h2:mem:test + url: jdbc:h2:mem:touroot username: sa jpa: - show-sql: true + show-sql: false properties: hibernate: - format_sql: true + dialect: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop defer-datasource-initialization: true + +security: + jwt: + token: + secret-key: ENC(SNwFG2NQDZkmIK3nNoZFdwQ0ZxKuoe+qcw10ljdW941YEx/Qky9PEEl+wvAN9S1KR26D3a83SnU=) + expire-length: 1800000 # 30 min + refresh: + secret-key: ENC(tneEW6IKq9XuDoxAoKvBEVER4xjLHCycWXMa+Rnzb700ndTnrkJ2mOtBPP5hEIJLRNgj5MLIhYs=) + expire-length: 1209600000 # 14 days +--- +# dev profile +spring: + config: + activate: + on-profile: dev + servlet: + multipart: + max-file-size: 25MB + max-request-size: 250MB + h2: + console: + enabled: false + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ENC(FgbVXdH4a5/EkMxdmYfPhFKAOwn1w+/CnmWDcN9p6fOVP6mP9coMEYezCPCNf95h) + username: ENC(SJznQPqjlZuw3qf8kv9IJQ==) + password: ENC(HsOo6wWp//egPPsSG6Wf40eF1Q2sVKfGuH4zGTL81Mw=) + jpa: + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: none + +server: + ssl: + key-store-type: PKCS12 + key-store-password: ENC(faQYah2QoIaNVRZD9J6/junPRWkc5gaiAs+mEbxDk+I=) + key-store: ENC(7VQCNdI7mXATwc4AiymZoyf3mz9SiskXpLnenpMSFBI=) + +security: + jwt: + token: + secret-key: ENC(L36WWjoZtP2nHHkqxDGlYLsMHMp+EBL2Fnl+X2de2KHk+PIfViyVM7rCYcbcFpo7yB4MaP++atU=) + expire-length: 1800000 # 30 min + refresh: + secret-key: ENC(cDstTL4/ajLm3NohJwMR9vEBsIZeD9Vt+jE1obdwL8Q6gMnWvY3N+bmNsC9N0csaa6AaYIZLbFM=) + expire-length: 1209600000 # 14 days +--- +# prod profile +spring: + config: + activate: + on-profile: prod + servlet: + multipart: + max-file-size: 25MB + max-request-size: 250MB + h2: + console: + enabled: false + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ENC(BLlw5HyZCuuLnw9MN5Ez395f+9INR7KoyWKUArAGc5QuMNJw07P06/1HLSZZ6y8M) + username: ENC(s5TCLskHnyopzJBsWU9Akg==) + password: ENC(MlRZxamsKXaRANlE3dX9T3vrdJhtsoE0r6LvSaKZoSU=) + jpa: + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: none + +server: + ssl: + key-store-type: PKCS12 + key-store-password: ENC(WyJqMojCOPDe932QhOR52Ll0EhthbUe5ycHs0WWifQg=) + key-store: ENC(DtVtAx+kVnU581+bjT7A3vCLbJ8fe3JqUritO+0WJ/Q=) + +security: + jwt: + token: + secret-key: ENC(1oFbXL7gi5RoZ3477IE75WnKK6t/Mq18IRzKB4TjRI/kb5ViYqbkBesMhPzjakFnAeqwiIWg0cQ=) + expire-length: 1800000 # 30 min + refresh: + secret-key: ENC(KzrDzCSz4dIMMP6Vsyd9cYvOvGXMbetrCDAX0/IpHfVW7kdsbV2+ZlGcBz+RCS2whMPuoCxdhyE=) + expire-length: 1209600000 # 14 days + +springdoc: + api-docs: + enabled: false diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..134165e8 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,96 @@ + + + + + + + + + ${CONSOLE_PATTERN} + + + + + + INFO + ACCEPT + DENY + + + ./log/info/info-${NOW}.log + + + ${LOG_PATTERN} + + + ./log/backup/info/info-%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 3GB + + + + + + WARN + ACCEPT + DENY + + + ./log/warn/warn-${NOW}.log + + + ${LOG_PATTERN} + + + ./log/backup/warn/warn-%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 3GB + + + + + + ERROR + ACCEPT + DENY + + + ./log/error/error-${NOW}.log + + + ${LOG_PATTERN} + + + ./log/backup/error/error-%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 3GB + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/test/java/kr/touroot/authentication/controller/LoginControllerTest.java b/backend/src/test/java/kr/touroot/authentication/controller/LoginControllerTest.java new file mode 100644 index 00000000..df48c85c --- /dev/null +++ b/backend/src/test/java/kr/touroot/authentication/controller/LoginControllerTest.java @@ -0,0 +1,85 @@ +package kr.touroot.authentication.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import kr.touroot.authentication.dto.request.TokenReissueRequest; +import kr.touroot.authentication.dto.response.TokenResponse; +import kr.touroot.authentication.fixture.OauthUserInformationFixture; +import kr.touroot.authentication.helper.LoginTestHelper; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.authentication.infrastructure.KakaoOauthProvider; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.member.domain.Member; +import kr.touroot.utils.DatabaseCleaner; +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.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; + +@AcceptanceTest +class LoginControllerTest { + + @Autowired + private JwtTokenProvider jwtTokenProvider; + @Autowired + private DatabaseCleaner databaseCleaner; + @Autowired + private LoginTestHelper testHelper; + @MockBean + private KakaoOauthProvider oauthProvider; + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + databaseCleaner.executeTruncate(); + } + + @DisplayName("카카오 로그인 요청을 처리할 수 있다") + @Test + void loginTest() throws Exception { + when(oauthProvider.getUserInformation(any(String.class), any(String.class))) + .thenReturn(OauthUserInformationFixture.USER_1_OAUTH_INFORMATION); + + RestAssured.given().log().all() + .queryParam("code", "test") + .queryParam("redirectUri", "https://test") + .when().log().all() + .post("/api/v1/login/oauth/kakao") + .then().log().all() + .statusCode(200) + .body("memberId", is(1)); + } + + @DisplayName("리프레시 토큰으로 재로그인을 할 수 있다.") + @Test + void reissueToken() { + // given + Member member = testHelper.initMemberTestData(); + TokenResponse tokenResponse = jwtTokenProvider.createToken(member.getId()); + TokenReissueRequest tokenReissueRequest = new TokenReissueRequest(tokenResponse.refreshToken()); + + // when + String accessToken = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(tokenReissueRequest) + .when().log().all() + .post("/api/v1/login/reissue-token") + .then().log().all() + .statusCode(200) + .extract() + .jsonPath().get("accessToken"); + String actual = jwtTokenProvider.decodeAccessToken(accessToken); + + // then + assertThat(actual).isEqualTo("1"); + } +} diff --git a/backend/src/test/java/kr/touroot/authentication/fixture/OauthUserInformationFixture.java b/backend/src/test/java/kr/touroot/authentication/fixture/OauthUserInformationFixture.java new file mode 100644 index 00000000..dbcfeba6 --- /dev/null +++ b/backend/src/test/java/kr/touroot/authentication/fixture/OauthUserInformationFixture.java @@ -0,0 +1,12 @@ +package kr.touroot.authentication.fixture; + +import kr.touroot.authentication.dto.response.OauthUserInformationResponse; +import kr.touroot.authentication.dto.response.kakao.KakaoAccount; +import kr.touroot.authentication.dto.response.kakao.KakaoProfile; + +public class OauthUserInformationFixture { + + public static final OauthUserInformationResponse USER_1_OAUTH_INFORMATION = new OauthUserInformationResponse( + 1L, new KakaoAccount(new KakaoProfile("리비", "http://img-url.com")) + ); +} diff --git a/backend/src/test/java/kr/touroot/authentication/helper/LoginTestHelper.java b/backend/src/test/java/kr/touroot/authentication/helper/LoginTestHelper.java new file mode 100644 index 00000000..a29fae6d --- /dev/null +++ b/backend/src/test/java/kr/touroot/authentication/helper/LoginTestHelper.java @@ -0,0 +1,19 @@ +package kr.touroot.authentication.helper; + +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import kr.touroot.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LoginTestHelper { + + private final MemberRepository memberRepository; + + public Member initMemberTestData() { + Member member = MemberFixture.DEFAULT_MEMBER.build(); + return memberRepository.save(member); + } +} diff --git a/backend/src/test/java/kr/touroot/authentication/infrastructure/JwtTokenProviderTest.java b/backend/src/test/java/kr/touroot/authentication/infrastructure/JwtTokenProviderTest.java new file mode 100644 index 00000000..897dde98 --- /dev/null +++ b/backend/src/test/java/kr/touroot/authentication/infrastructure/JwtTokenProviderTest.java @@ -0,0 +1,31 @@ +package kr.touroot.authentication.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("JWT") +class JwtTokenProviderTest { + + private static final String TEST_SECRET_KEY = "test-secret-key-clover-leegun-naknak-alpaka-libi"; + private static final String TEST_REFRESH_KEY = "test-secret-key-clover-leegun-naknak-alpaka-libi"; + private static final int EXPIRATION_TIME_30_MIN = 1800000; + private static final int EXPIRATION_TIME_14_DAYS = 1209600000; + + private JwtTokenProvider jwtTokenProvider; + + @DisplayName("멤버를 통해 토큰을 만들 수 있다") + @Test + void createTokenTest() { + jwtTokenProvider = new JwtTokenProvider( + TEST_SECRET_KEY, + TEST_REFRESH_KEY, + EXPIRATION_TIME_30_MIN, + EXPIRATION_TIME_14_DAYS + ); + + assertThat(jwtTokenProvider.createToken(1L)) + .isNotNull(); + } +} diff --git a/backend/src/test/java/kr/touroot/authentication/service/LoginServiceTest.java b/backend/src/test/java/kr/touroot/authentication/service/LoginServiceTest.java new file mode 100644 index 00000000..b133c3e2 --- /dev/null +++ b/backend/src/test/java/kr/touroot/authentication/service/LoginServiceTest.java @@ -0,0 +1,88 @@ +package kr.touroot.authentication.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.touroot.authentication.dto.response.LoginResponse; +import kr.touroot.authentication.dto.response.TokenResponse; +import kr.touroot.authentication.fixture.OauthUserInformationFixture; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.authentication.infrastructure.KakaoOauthProvider; +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import kr.touroot.member.repository.MemberRepository; +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; + +@DisplayName("로그인 서비스") +@ExtendWith(MockitoExtension.class) +class LoginServiceTest { + + private static final String AUTHENTICATION_CODE = "test-authentication-code"; + private static final String REDIRECT_URI = "http%3A%2F%2Flocalhost%3A8080%2Fapi%2Fv1%2Flogin%2Foauth%2Fkakao"; + private static final Member MEMBER = MemberFixture.KAKAO_MEMBER.build(); + + @InjectMocks + private LoginService loginService; + @Mock + private MemberRepository memberRepository; + @Mock + private KakaoOauthProvider kakaoOauthProvider; + @Mock + private JwtTokenProvider jwtTokenProvider; + + @DisplayName("투룻 회원가입이 되어 있는 회원의 카카오 소셜 로그인을 처리할 수 있다") + @Test + void existUserKakaoSocialLoginTest() { + // given + String accessToken = "aaa"; + String refreshToken = "bbb"; + + when(kakaoOauthProvider.getUserInformation(any(String.class), any(String.class))) + .thenReturn(OauthUserInformationFixture.USER_1_OAUTH_INFORMATION); + when(memberRepository.findByKakaoId(any(Long.class))) + .thenReturn(Optional.of(MEMBER)); + when(jwtTokenProvider.createToken(MEMBER.getId())) + .thenReturn(new TokenResponse(accessToken, refreshToken)); + + LoginResponse response = loginService.login(AUTHENTICATION_CODE, REDIRECT_URI); + + // when & then + assertThat(response).isEqualTo( + LoginResponse.of(MEMBER, new TokenResponse(response.accessToken(), response.refreshToken())) + ); + } + + @DisplayName("투룻 회원가입이 되어 있지 않은 회원은 소셜 로그인 과정에서 회원가입 후 로그인 된다") + @Test + void nonExistUserKakaoSocialLoginTest() { + // given + String accessToken = "aaa"; + String refreshToken = "bbb"; + + when(kakaoOauthProvider.getUserInformation(any(String.class), any(String.class))) + .thenReturn(OauthUserInformationFixture.USER_1_OAUTH_INFORMATION); + when(memberRepository.findByKakaoId(any(Long.class))) + .thenReturn(Optional.empty()); + when(memberRepository.save(any(Member.class))) + .thenReturn(MEMBER); + when(jwtTokenProvider.createToken(MEMBER.getId())) + .thenReturn(new TokenResponse(accessToken, refreshToken)); + + LoginResponse response = loginService.login(AUTHENTICATION_CODE, REDIRECT_URI); + + // when & then + assertThat(response).isEqualTo( + LoginResponse.of(MEMBER, new TokenResponse(response.accessToken(), response.refreshToken())) + ); + verify(memberRepository, times(1)).save(any(Member.class)); + } +} diff --git a/backend/src/test/java/kr/touroot/global/AcceptanceTest.java b/backend/src/test/java/kr/touroot/global/AcceptanceTest.java new file mode 100644 index 00000000..248d0a37 --- /dev/null +++ b/backend/src/test/java/kr/touroot/global/AcceptanceTest.java @@ -0,0 +1,13 @@ +package kr.touroot.global; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Retention(RetentionPolicy.RUNTIME) +@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"}) +public @interface AcceptanceTest { +} diff --git a/backend/src/test/java/kr/touroot/global/ServiceTest.java b/backend/src/test/java/kr/touroot/global/ServiceTest.java new file mode 100644 index 00000000..8422e47b --- /dev/null +++ b/backend/src/test/java/kr/touroot/global/ServiceTest.java @@ -0,0 +1,18 @@ +package kr.touroot.global; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import kr.touroot.utils.DatabaseCleaner; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@DataJpaTest +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import(value = {DatabaseCleaner.class}) +@Retention(RetentionPolicy.RUNTIME) +@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"}) +public @interface ServiceTest { +} diff --git a/backend/src/test/java/kr/touroot/global/config/TestQueryDslConfig.java b/backend/src/test/java/kr/touroot/global/config/TestQueryDslConfig.java new file mode 100644 index 00000000..60d308ba --- /dev/null +++ b/backend/src/test/java/kr/touroot/global/config/TestQueryDslConfig.java @@ -0,0 +1,42 @@ +package kr.touroot.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.touroot.travelogue.repository.query.TravelogueQueryRepository; +import kr.touroot.travelogue.repository.query.TravelogueQueryRepositoryImpl; +import kr.touroot.travelogue.repository.query.TravelogueTagQueryRepository; +import kr.touroot.travelogue.repository.query.TravelogueTagQueryRepositoryImpl; +import kr.touroot.travelplan.repository.PlaceTodoQueryRepository; +import kr.touroot.travelplan.repository.PlaceTodoQueryRepositoryImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@RequiredArgsConstructor +@TestConfiguration +public class TestQueryDslConfig { + + @PersistenceContext + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + + @Bean + public TravelogueQueryRepository travelogueQueryRepository() { + return new TravelogueQueryRepositoryImpl(jpaQueryFactory()); + } + + @Bean + public TravelogueTagQueryRepository travelogueTagQueryRepository() { + return new TravelogueTagQueryRepositoryImpl(jpaQueryFactory()); + } + + @Bean + public PlaceTodoQueryRepository placeTodoQueryRepository() { + return new PlaceTodoQueryRepositoryImpl(jpaQueryFactory()); + } +} diff --git a/backend/src/test/java/kr/touroot/image/domain/ImageFileTest.java b/backend/src/test/java/kr/touroot/image/domain/ImageFileTest.java new file mode 100644 index 00000000..d0ed406a --- /dev/null +++ b/backend/src/test/java/kr/touroot/image/domain/ImageFileTest.java @@ -0,0 +1,69 @@ +package kr.touroot.image.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("이미지 파일") +class ImageFileTest { + + private MultipartFile validFile; + private MultipartFile nullFile; + private MultipartFile originalFileNameNullFile; + private MultipartFile notSupportingFile; + private MultipartFile fileWithNoExtension; + + @BeforeEach + void setUp() { + validFile = new MockMultipartFile("file", "image.jpg", "image/jpeg", "image content".getBytes()); + nullFile = null; + originalFileNameNullFile = new MockMultipartFile("file", null, "text/plain", "image content".getBytes()); + notSupportingFile = new MockMultipartFile("file", "image.txt", "text/plain", "image content".getBytes()); + fileWithNoExtension = new MockMultipartFile("file", "image.", "text/plain", "image content".getBytes()); + } + + @DisplayName("유효한 파일로 이미지 파일 도메인을 생성할 수 있다") + @Test + void createImageFileWithValidFile() { + assertThatCode(() -> new ImageFile(validFile)) + .doesNotThrowAnyException(); + } + + @DisplayName("파일이 null이면 이미지 파일 생성 시에 예외가 발생한다") + @Test + void createImageFileWithNullFile() { + assertThatThrownBy(() -> new ImageFile(nullFile)) + .isInstanceOf(BadRequestException.class) + .hasMessage("파일을 전달 받지 못했습니다"); + } + + @DisplayName("파일 이름이 비어있는 경우 이미지 파일 생성 시에 예외가 발생한다") + @Test + void createImageFileWithOriginalFileNameNullFile() { + assertThatThrownBy(() -> new ImageFile(originalFileNameNullFile)) + .isInstanceOf(BadRequestException.class) + .hasMessage("파일 이름은 비어있을 수 없습니다"); + } + + @DisplayName("지원하지 않는 파일 확장자의 이미지 파일을 생성 시에 에외가 발생한다") + @Test + void createImageFileWithNotValidExtensionFile() { + assertThatThrownBy(() -> new ImageFile(notSupportingFile)) + .isInstanceOf(BadRequestException.class) + .hasMessage("지원하지 않는 확장자입니다: " + "txt"); + } + + @DisplayName("파일 이름 형식이 올바르지 않은 경우 이미지 파일 생성 시에 예외가 발생한다") + @Test + void createImageFileWithInvalidFormFile() { + assertThatThrownBy(() -> new ImageFile(fileWithNoExtension)) + .isInstanceOf(BadRequestException.class) + .hasMessage("파일 형식이 잘못되었습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/image/infrastructure/AwsS3ProviderTest.java b/backend/src/test/java/kr/touroot/image/infrastructure/AwsS3ProviderTest.java new file mode 100644 index 00000000..64bc3d43 --- /dev/null +++ b/backend/src/test/java/kr/touroot/image/infrastructure/AwsS3ProviderTest.java @@ -0,0 +1,59 @@ +package kr.touroot.image.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.global.exception.S3UploadException; +import kr.touroot.image.domain.ImageFile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@AcceptanceTest +class AwsS3ProviderTest { + + private final AwsS3Provider s3Provider; + private final String temporaryStoragePath; + private final String imageStoragePath; + + public AwsS3ProviderTest( + @Autowired AwsS3Provider s3Provider, + @Value("${cloud.aws.s3.temporary-storage-path}") String temporaryStoragePath, + @Value("${cloud.aws.s3.image-storage-path}") String imageStoragePath + ) { + this.s3Provider = s3Provider; + this.temporaryStoragePath = temporaryStoragePath; + this.imageStoragePath = imageStoragePath; + } + + @DisplayName("유효한 url을 통해 이미지를 영구 폴더로 복사하면 새로운 url을 반환한다.") + @Test + void copyImageToPermanentStorage() { + MultipartFile multipartFile = new MockMultipartFile( + "file", + "test-image.png", + "image/png", + "test image content".getBytes() + ); + String temporaryUrl = s3Provider.uploadImages(List.of(new ImageFile(multipartFile))) + .get(0); + String imageUrl = temporaryUrl.replace(temporaryStoragePath, imageStoragePath); + + assertThat(s3Provider.copyImageToPermanentStorage(temporaryUrl)) + .isEqualTo(imageUrl); + } + + @DisplayName("url이 올바른 버킷명과 폴더명으로 시작하지 않으면 예외를 발생한다.") + @Test + void copyImageToPermanentStorageWithInvalidPath() { + String imageUrl = "invalid/testUrl.png"; + assertThatThrownBy(() -> s3Provider.copyImageToPermanentStorage(imageUrl)) + .isInstanceOf(S3UploadException.class) + .hasMessage("S3 이미지 url 형식이 잘못되었습니다."); + } +} diff --git a/backend/src/test/java/kr/touroot/member/controller/MemberControllerTest.java b/backend/src/test/java/kr/touroot/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..a2f4309c --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/controller/MemberControllerTest.java @@ -0,0 +1,111 @@ +package kr.touroot.member.controller; + +import static kr.touroot.member.fixture.MemberRequestFixture.EMPTY_EMAIL_MEMBER; +import static kr.touroot.member.fixture.MemberRequestFixture.EMPTY_NICKNAME_MEMBER; +import static kr.touroot.member.fixture.MemberRequestFixture.EMPTY_PASSWORD_MEMBER; +import static kr.touroot.member.fixture.MemberRequestFixture.EMPTY_PROFILE_IMAGE_URL_MEMBER; +import static kr.touroot.member.fixture.MemberRequestFixture.VALID_MEMBER; +import static org.hamcrest.Matchers.is; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.member.dto.request.MemberRequest; +import kr.touroot.utils.DatabaseCleaner; +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.web.server.LocalServerPort; + +@DisplayName("사용자 컨트롤러") +@AcceptanceTest +class MemberControllerTest { + + private final DatabaseCleaner databaseCleaner; + + @LocalServerPort + private int port; + + @Autowired + public MemberControllerTest(DatabaseCleaner databaseCleaner) { + this.databaseCleaner = databaseCleaner; + } + + @BeforeEach + void setUp() { + RestAssured.port = port; + + databaseCleaner.executeTruncate(); + } + + @DisplayName("회원 가입을 한다.") + @Test + void createTravelogue() { + MemberRequest request = VALID_MEMBER.getRequest(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/members") + .then().log().all() + .statusCode(201) + .header("Location", "/api/v1/members/1"); + } + + @DisplayName("비어있는 이메일로 회원 가입하면 예외가 발생한다.") + @Test + void createTravelogueWithEmptyEmail() { + MemberRequest request = EMPTY_EMAIL_MEMBER.getRequest(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/members") + .then().log().all() + .statusCode(400) + .body("message", is("이메일은 비어있을 수 없습니다.")); + } + + @DisplayName("비어있는 비밀번호로 회원 가입하면 예외가 발생한다.") + @Test + void createTravelogueWithEmptyPassword() { + MemberRequest request = EMPTY_PASSWORD_MEMBER.getRequest(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/members") + .then().log().all() + .statusCode(400) + .body("message", is("비밀번호는 비어있을 수 없습니다.")); + } + + @DisplayName("비어있는 닉네임으로 회원 가입하면 예외가 발생한다.") + @Test + void createTravelogueWithEmptyNickname() { + MemberRequest request = EMPTY_NICKNAME_MEMBER.getRequest(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/members") + .then().log().all() + .statusCode(400) + .body("message", is("닉네임은 비어있을 수 없습니다.")); + } + + @DisplayName("비어있는 프로필 사진 경로로 회원 가입하면 예외가 발생한다.") + @Test + void createTravelogueWithEmptyProfileImageUrl() { + MemberRequest request = EMPTY_PROFILE_IMAGE_URL_MEMBER.getRequest(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/members") + .then().log().all() + .statusCode(400) + .body("message", is("프로필 사진 URL은 비어있을 수 없습니다.")); + } +} diff --git a/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java b/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java new file mode 100644 index 00000000..4df72d07 --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java @@ -0,0 +1,114 @@ +package kr.touroot.member.controller; + +import static org.hamcrest.Matchers.is; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.member.domain.Member; +import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.travelplan.helper.TravelPlanTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; + +@DisplayName("마이 페이지 컨트롤러") +@AcceptanceTest +class MyPageControllerTest { + + private final DatabaseCleaner databaseCleaner; + private final JwtTokenProvider jwtTokenProvider; + private final TravelogueTestHelper travelogueTestHelper; + private final TravelPlanTestHelper travelPlanTestHelper; + + @LocalServerPort + private int port; + private String accessToken; + private Member member; + + @Autowired + public MyPageControllerTest( + DatabaseCleaner databaseCleaner, + JwtTokenProvider jwtTokenProvider, + TravelogueTestHelper travelogueTestHelper, + TravelPlanTestHelper travelPlanTestHelper + ) { + this.databaseCleaner = databaseCleaner; + this.jwtTokenProvider = jwtTokenProvider; + this.travelogueTestHelper = travelogueTestHelper; + this.travelPlanTestHelper = travelPlanTestHelper; + } + + @BeforeEach + void setUp() { + RestAssured.port = port; + databaseCleaner.executeTruncate(); + + member = travelogueTestHelper.initKakaoMemberTestData(); + accessToken = jwtTokenProvider.createToken(member.getId()) + .accessToken(); + } + + @DisplayName("마이 페이지 컨트롤러는 내 여행기 조회 요청이 들어오면 로그인한 사용자의 여행기를 조회한다.") + @Test + void readTravelogues() { + // given + travelogueTestHelper.initTravelogueTestData(member); + travelogueTestHelper.initTravelogueTestData(member); + travelogueTestHelper.initTravelogueTestData(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/member/me/travelogues") + .then().log().all() + .statusCode(200) + .body("content.size()", is(2)); + } + + @DisplayName("마이 페이지 컨트롤러는 내 여행계획 조회 시 요청이 들어오면 로그인한 사용자의 여행 계획을 조회한다.") + @Test + void readTravelPlans() { + // given + travelPlanTestHelper.initTravelPlanTestData(member); + travelPlanTestHelper.initTravelPlanTestData(member); + travelPlanTestHelper.initTravelPlanTestData(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/member/me/travel-plans") + .then().log().all() + .statusCode(200) + .body("content.size()", is(2)); + } + + @DisplayName("마이 페이지 컨트롤러는 내 프로필 수정 요청이 들어오면 로그인한 사용자의 프로필을 수정한다.") + @Test + void updateProfile() { + // given + String newNickname = "newNickname"; + ProfileUpdateRequest request = new ProfileUpdateRequest(newNickname); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().log().all() + .patch("/api/v1/member/me/profile") + .then().log().all() + .statusCode(200) + .body("nickname", is(newNickname)); + } +} diff --git a/backend/src/test/java/kr/touroot/member/domain/MemberTest.java b/backend/src/test/java/kr/touroot/member/domain/MemberTest.java new file mode 100644 index 00000000..26c1ca87 --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/domain/MemberTest.java @@ -0,0 +1,120 @@ +package kr.touroot.member.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("멤버") +class MemberTest { + + private static final Long VALID_SOCIAl_ID = 1L; + private static final String VALID_EMAIL = "user@email.com"; + private static final String VALID_NICKNAME = "nickname"; + private static final String VALID_PROFILE_IMAGE_URL = "https://dev.touroot.kr/images/ttouri.png"; + private static final LoginType DEFAULT = LoginType.DEFAULT; + private static final LoginType KAKAO = LoginType.KAKAO; + private static final String VALID_PASSWORD = "5304d46adc6ccffd0abf352f3e17ba8b807dc3d5e5d12609d0825d7287"; + + @DisplayName("검증 규칙을 통과하는 멤버 생성은 예외가 발생하지 않는다") + @Test + void createMemberWithValidData() { + assertThatCode(() -> new Member(VALID_SOCIAl_ID, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, KAKAO)) + .doesNotThrowAnyException(); + } + + @DisplayName("자체 로그인 시, 이메일과 비밀번호가 null일 때 멤버 생성 시 예외가 발생한다") + @Test + void createMemberWithEmailAndPasswordNull() { + assertThatThrownBy(() -> new Member(null, null, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, DEFAULT)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이메일과 비밀번호는 비어 있을 수 없습니다."); + } + + @DisplayName("자체 로그인 시, 이메일이 비어 있는 경우 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void createMemberWithBlankEmail(String blankEmail) { + assertThatThrownBy( + () -> new Member(blankEmail, VALID_PASSWORD, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, DEFAULT)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이메일과 비밀번호는 비어 있을 수 없습니다."); + } + + @DisplayName("카카오 로그인 시, 카카오 아이디가 null일 때 멤버 생성 시 예외가 발생한다") + @Test + void createMemberWithKakaoIdNull() { + assertThatThrownBy(() -> new Member(null, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, KAKAO)) + .isInstanceOf(BadRequestException.class) + .hasMessage("카카오 ID는 비어 있을 수 없습니다"); + } + + @DisplayName("닉네임이 null이거나 비어 있는 경우 멤버 생성 시 예외가 발생한다") + @ParameterizedTest + @NullAndEmptySource() + void createMemberWithNullOrEmptyNickname(String nullOrEmptyNickname) { + assertThatThrownBy(() -> new Member(VALID_SOCIAl_ID, nullOrEmptyNickname, VALID_PROFILE_IMAGE_URL, KAKAO)) + .isInstanceOf(BadRequestException.class) + .hasMessage("닉네임은 비어 있을 수 없습니다"); + } + + @DisplayName("프로필 이미지 경로가 null이거나 비어 있는 경우 멤버 생성 시 예외가 발생한다") + @ParameterizedTest + @NullAndEmptySource() + void createMemberWithNullOrEmptyProfileImage(String nullOrEmptyUrl) { + assertThatThrownBy(() -> new Member(VALID_SOCIAl_ID, VALID_NICKNAME, nullOrEmptyUrl, KAKAO)) + .isInstanceOf(BadRequestException.class) + .hasMessage("프로필 이미지는 비어 있을 수 없습니다"); + } + + @DisplayName("닉네임의 길이가 범위를 벗어나면 멤버 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"21-length-nicknameeee", "22-length-nicknameeeee"}) + void createMemberWithInvalidLengthNickname(String invalidLengthNickname) { + assertThatThrownBy(() -> new Member(VALID_SOCIAl_ID, invalidLengthNickname, VALID_PROFILE_IMAGE_URL, KAKAO)) + .isInstanceOf(BadRequestException.class) + .hasMessage("닉네임은 1자 이상, 20자 이하여야 합니다"); + } + + @DisplayName("프로필 이미지 url의 형식이 잘못된 경우 멤버 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"htt:touroot.kr", "touroot.kr"}) + void createMemberWithInvalidProfileImageUrl(String invalidProfileImageUrl) { + assertThatThrownBy(() -> new Member(VALID_SOCIAl_ID, VALID_NICKNAME, invalidProfileImageUrl, KAKAO)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이미지 url 형식이 잘못되었습니다"); + } + + @DisplayName("검증 규칙을 통과하는 닉네임 변경은 예외가 발생하지 않는다") + @Test + void changeNicknameWithValidData() { + Member member = new Member(VALID_SOCIAl_ID, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, KAKAO); + assertThatCode(() -> member.changeNickname(VALID_NICKNAME + "a")) + .doesNotThrowAnyException(); + } + + @DisplayName("null이나 비어있는 닉네임으로 변경 시 예외가 발생한다") + @ParameterizedTest + @NullAndEmptySource() + void changeNicknameWithOrEmpty(String nullOrEmptyNickname) { + Member member = new Member(VALID_SOCIAl_ID, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, KAKAO); + assertThatThrownBy(() -> member.changeNickname(nullOrEmptyNickname)) + .isInstanceOf(BadRequestException.class) + .hasMessage("닉네임은 비어 있을 수 없습니다"); + } + + @DisplayName("범위를 벗어난 길이의 닉네임으로 변경 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"21-length-nicknameeee", "22-length-nicknameeeee"}) + void changeNicknameWithInvalidLength(String invalidLengthNickname) { + Member member = new Member(VALID_SOCIAl_ID, VALID_NICKNAME, VALID_PROFILE_IMAGE_URL, KAKAO); + assertThatThrownBy(() -> member.changeNickname(invalidLengthNickname)) + .isInstanceOf(BadRequestException.class) + .hasMessage("닉네임은 1자 이상, 20자 이하여야 합니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/member/fixture/MemberFixture.java b/backend/src/test/java/kr/touroot/member/fixture/MemberFixture.java new file mode 100644 index 00000000..0fb3c440 --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/fixture/MemberFixture.java @@ -0,0 +1,30 @@ +package kr.touroot.member.fixture; + +import kr.touroot.member.domain.LoginType; +import kr.touroot.member.domain.Member; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum MemberFixture { + + KAKAO_MEMBER(1L, null, null, "https://dev.touroot.kr/temporary/profile.png", "리비", + LoginType.KAKAO), + DEFAULT_MEMBER(null, "email@gmail.com", "password", "https://dev.touroot.kr/temporary/profile.png", "뚜리", + LoginType.DEFAULT); + + private final Long socialId; + private final String email; + private final String password; + private final String profileImageUrl; + private final String nickname; + private final LoginType loginType; + + public Member build() { + if (loginType == LoginType.KAKAO) { + return new Member(socialId, nickname, profileImageUrl, loginType); + } + return new Member(email, password, nickname, profileImageUrl, loginType); + } +} diff --git a/backend/src/test/java/kr/touroot/member/fixture/MemberRequestFixture.java b/backend/src/test/java/kr/touroot/member/fixture/MemberRequestFixture.java new file mode 100644 index 00000000..a62f9105 --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/fixture/MemberRequestFixture.java @@ -0,0 +1,34 @@ +package kr.touroot.member.fixture; + +import kr.touroot.member.dto.request.MemberRequest; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum MemberRequestFixture { + + VALID_MEMBER( + new MemberRequest("user@email.com", "@testpassword", "뚜리", "https://dev.touroot.kr/images/f8c26e9f.png") + ), + EMPTY_EMAIL_MEMBER( + new MemberRequest("", "@testpassword", "뚜리", "https://dev.touroot.kr/images/f8c26e9f.png") + ), + EMPTY_PASSWORD_MEMBER( + new MemberRequest("user@email.com", "", "뚜리", "https://dev.touroot.kr/images/f8c26e9f.png") + ), + EMPTY_NICKNAME_MEMBER( + new MemberRequest("user@email.com", "@testpassword", "", "https://dev.touroot.kr/images/f8c26e9f.png") + ), + EMPTY_PROFILE_IMAGE_URL_MEMBER( + new MemberRequest("user@email.com", "@testpassword", "뚜리", "") + ), + DUPLICATE_EMAIL_MEMBER( + new MemberRequest("user@email.com", "@testpassword", "뚜리뚜리", "https://dev.touroot.kr/images/f8c26e9f.png") + ), + DUPLICATE_NICKNAME_MEMBER( + new MemberRequest("hello@email.com", "@testpassword", "뚜리", "https://dev.touroot.kr/images/f8c26e9f.png") + ); + + private final MemberRequest request; +} diff --git a/backend/src/test/java/kr/touroot/member/helper/MemberTestHelper.java b/backend/src/test/java/kr/touroot/member/helper/MemberTestHelper.java new file mode 100644 index 00000000..cc6908d6 --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/helper/MemberTestHelper.java @@ -0,0 +1,28 @@ +package kr.touroot.member.helper; + +import static kr.touroot.member.fixture.MemberFixture.DEFAULT_MEMBER; + +import kr.touroot.member.domain.Member; +import kr.touroot.member.repository.MemberRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class MemberTestHelper { + + private final MemberRepository memberRepository; + + @Autowired + public MemberTestHelper(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public Member persistMember() { + Member member = DEFAULT_MEMBER.build(); + return memberRepository.save(member); + } + + public Member persistMember(Member member) { + return memberRepository.save(member); + } +} diff --git a/backend/src/test/java/kr/touroot/member/service/MemberServiceTest.java b/backend/src/test/java/kr/touroot/member/service/MemberServiceTest.java new file mode 100644 index 00000000..f97ad67c --- /dev/null +++ b/backend/src/test/java/kr/touroot/member/service/MemberServiceTest.java @@ -0,0 +1,109 @@ +package kr.touroot.member.service; + +import static kr.touroot.member.fixture.MemberRequestFixture.DUPLICATE_NICKNAME_MEMBER; +import static kr.touroot.member.fixture.MemberRequestFixture.VALID_MEMBER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.authentication.infrastructure.PasswordEncryptor; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import kr.touroot.member.dto.request.MemberRequest; +import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.member.helper.MemberTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("사용자 서비스") +@Import(value = {MemberService.class, MemberTestHelper.class, PasswordEncryptor.class}) +@ServiceTest +class MemberServiceTest { + + private final MemberService memberService; + private final MemberTestHelper testHelper; + private final DatabaseCleaner databaseCleaner; + + @Autowired + public MemberServiceTest( + MemberService memberService, + MemberTestHelper testHelper, + DatabaseCleaner databaseCleaner + ) { + this.memberService = memberService; + this.testHelper = testHelper; + this.databaseCleaner = databaseCleaner; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("ID를 기준으로 회원을 조회한다.") + @Test + void getById() { + Member member = testHelper.persistMember(); + + assertThat(memberService.getById(member.getId()).getId()) + .isEqualTo(member.getId()); + } + + @DisplayName("ID를 기준으로 존재하지 않는 회원을 조회하면 예외가 발생한다.") + @Test + void getByIdNotExist() { + assertThatThrownBy(() -> memberService.getById(0L)) + .hasMessage("존재하지 않는 사용자입니다."); + } + + @DisplayName("정상적인 값을 가진 요청이 주어지면 회원을 생성한다.") + @Test + void createMember() { + MemberRequest request = VALID_MEMBER.getRequest(); + + Long id = memberService.createMember(request); + + assertThat(id).isEqualTo(1L); + } + + @DisplayName("중복된 이메일을 가진 회원을 생성하려하면 예외가 발생한다.") + @Test + void createMemberWithDuplicatedEmail() { + Member member = testHelper.persistMember(); + MemberRequest duplicateRequest = new MemberRequest(member.getEmail(), "testPassword", "nickname", + "https://dev.touroot.kr/images/f8c26e9f.png"); + + assertThatThrownBy(() -> memberService.createMember(duplicateRequest)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이미 회원 가입되어 있는 이메일입니다."); + } + + @DisplayName("중복된 이메일을 가진 회원을 생성하려하면 예외가 발생한다.") + @Test + void createMemberWithDuplicatedNickname() { + testHelper.persistMember(); + MemberRequest request = DUPLICATE_NICKNAME_MEMBER.getRequest(); + + assertThatThrownBy(() -> memberService.createMember(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이미 사용 중인 닉네임입니다."); + } + + @DisplayName("멤버의 프로필을 업데이트 한다.") + @Test + void updateProfile() { + Member member = testHelper.persistMember(); + MemberAuth memberAuth = new MemberAuth(member.getId()); + ProfileUpdateRequest request = new ProfileUpdateRequest("newNickname"); + + memberService.updateProfile(request, memberAuth); + + assertThat(memberService.getById(member.getId()).getNickname()) + .isEqualTo("newNickname"); + } +} diff --git a/backend/src/test/java/kr/touroot/place/domain/PlaceTest.java b/backend/src/test/java/kr/touroot/place/domain/PlaceTest.java new file mode 100644 index 00000000..3bbf3b35 --- /dev/null +++ b/backend/src/test/java/kr/touroot/place/domain/PlaceTest.java @@ -0,0 +1,104 @@ +package kr.touroot.place.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("장소") +class PlaceTest { + + private static final String VALID_PLACE_NAME = "유효한 장소 이름"; + private static final String VALID_LATITUDE = "37.5175896"; + private static final String VALID_LONGITUDE = "127.0867236"; + private static final String VALID_GOOGLE_PLACE_ID = "ChIJgUbEo8cfqokR5lP9_Wh_DaM"; + + @DisplayName("유효한 장소를 생성하는 경우 생성 시 예외가 발생하지 않는다") + @Test + void createPlaceWithValidData() { + assertThatCode(() -> new Place(VALID_PLACE_NAME, VALID_LATITUDE, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .doesNotThrowAnyException(); + } + + @DisplayName("장소 이름이 null인 경우 장소 생성 시 예외가 발생한다") + @Test + void createPlaceWithNullPlaceName() { + assertThatThrownBy(() -> new Place(null, VALID_LATITUDE, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + + @DisplayName("장소 이름의 길이가 60자를 초과하는 경우 장소 생성 시 예외가 발생한다") + @Test + void createPlaceWithInvalidLengthPlaceName() { + String length61 = "Under the summer sun, feeling the cool breeze by the sea is pure joy!!"; + assertThatThrownBy(() -> new Place(length61, VALID_LATITUDE, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름은 60자 이하여야 합니다"); + } + + @DisplayName("위도가 null인 경우 장소 생성 시 예외가 발생한다") + @Test + void createPlaceWithNullLatitude() { + assertThatThrownBy(() -> new Place(VALID_PLACE_NAME, null, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + + @DisplayName("경도가 null인 경우 장소 생성 시 예외가 발생한다") + @Test + void createPlaceWithNullLongitude() { + assertThatThrownBy(() -> new Place(VALID_PLACE_NAME, VALID_LATITUDE, null, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + + @DisplayName("장소 이름이 비어 있는 경우 장소 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {" ", " "}) + void createPlaceWithBlankPlaceName(String blank) { + assertThatThrownBy(() -> new Place(blank, VALID_LATITUDE, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + + @DisplayName("위도가 비어 있는 경우 장소 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {" ", " "}) + void createPlaceWithBlankLatitude(String blank) { + assertThatThrownBy(() -> new Place(VALID_PLACE_NAME, blank, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + + @DisplayName("경도가 비어 있는 경우 장소 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {" ", " "}) + void createPlaceWithBlankLongitude(String blank) { + assertThatThrownBy(() -> new Place(VALID_PLACE_NAME, VALID_LATITUDE, blank, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소 이름, 위도, 경도는 비어 있을 수 없습니다"); + } + + @DisplayName("위도 형식에 맞지 않는 위도로 장소 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"91.0000", "-91.0000", "100.0000", "-100.0000", "abc", "45.0000.0", "45,0000"}) + void createPlaceWithMalformedLatitude(String malformed) { + assertThatThrownBy(() -> new Place(VALID_PLACE_NAME, malformed, VALID_LONGITUDE, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("위,경도의 형식이 올바르지 않습니다"); + } + + @DisplayName("경도 형식에 맞지 않는 위도로 장소 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"181.0000", "-181.0000", "200.0000", "-200.0000", "abc", "100.0000.0", "100,0000"}) + void createPlaceWithMalformedLongitude(String malformed) { + assertThatThrownBy(() -> new Place(VALID_PLACE_NAME, VALID_LATITUDE, malformed, VALID_GOOGLE_PLACE_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessage("위,경도의 형식이 올바르지 않습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/place/fixture/PlaceFixture.java b/backend/src/test/java/kr/touroot/place/fixture/PlaceFixture.java new file mode 100644 index 00000000..a125a994 --- /dev/null +++ b/backend/src/test/java/kr/touroot/place/fixture/PlaceFixture.java @@ -0,0 +1,19 @@ +package kr.touroot.place.fixture; + +import kr.touroot.place.domain.Place; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum PlaceFixture { + + PLACE("함덕해수욕장", "34.54343", "126.66977", ""); + + private final String name; + private final String latitude; + private final String longitude; + private final String googlePlaceId; + + public Place get() { + return new Place(name, latitude, longitude, googlePlaceId); + } +} diff --git a/backend/src/test/java/kr/touroot/tag/TagControllerTest.java b/backend/src/test/java/kr/touroot/tag/TagControllerTest.java new file mode 100644 index 00000000..53bfbfc0 --- /dev/null +++ b/backend/src/test/java/kr/touroot/tag/TagControllerTest.java @@ -0,0 +1,56 @@ +package kr.touroot.tag; + +import static org.hamcrest.Matchers.is; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.tag.dto.TagCreateRequest; +import kr.touroot.tag.fixture.TagFixture; +import kr.touroot.tag.helper.TagTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.web.server.LocalServerPort; + +@DisplayName("태그 컨트롤러") +@AcceptanceTest +class TagControllerTest { + + private final DatabaseCleaner databaseCleaner; + private final TagTestHelper testHelper; + + @LocalServerPort + private int port; + + @Autowired + public TagControllerTest(DatabaseCleaner databaseCleaner, TagTestHelper testHelper) { + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + RestAssured.port = port; + databaseCleaner.executeTruncate(); + } + + @DisplayName("태그 컨트롤러는 태그 생성 요청 시 201을 응답한다.") + @Test + void createTag() { + // given + TagCreateRequest request = TagFixture.TAG_1.getCreateRequest(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().log().all() + .post("/api/v1/tags") + .then().log().all() + .statusCode(201) + .header("Location", is("/api/v1/tags/1")); + } +} diff --git a/backend/src/test/java/kr/touroot/tag/fixture/TagFixture.java b/backend/src/test/java/kr/touroot/tag/fixture/TagFixture.java new file mode 100644 index 00000000..dcd3e47d --- /dev/null +++ b/backend/src/test/java/kr/touroot/tag/fixture/TagFixture.java @@ -0,0 +1,31 @@ +package kr.touroot.tag.fixture; + +import kr.touroot.tag.domain.Tag; +import kr.touroot.tag.dto.TagCreateRequest; +import kr.touroot.tag.dto.TagResponse; + +public enum TagFixture { + + TAG_1("강아지와 함께"), + TAG_2("고양이와 함께"), + TAG_3("알파카와 함께"), + ; + + private final String tag; + + TagFixture(String tag) { + this.tag = tag; + } + + public Tag get() { + return new Tag(tag); + } + + public TagCreateRequest getCreateRequest() { + return new TagCreateRequest(tag); + } + + public TagResponse getResponse(Long id) { + return new TagResponse(id, tag); + } +} diff --git a/backend/src/test/java/kr/touroot/tag/helper/TagTestHelper.java b/backend/src/test/java/kr/touroot/tag/helper/TagTestHelper.java new file mode 100644 index 00000000..169ae524 --- /dev/null +++ b/backend/src/test/java/kr/touroot/tag/helper/TagTestHelper.java @@ -0,0 +1,27 @@ +package kr.touroot.tag.helper; + +import kr.touroot.tag.domain.Tag; +import kr.touroot.tag.fixture.TagFixture; +import kr.touroot.tag.repository.TagRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TagTestHelper { + + private final TagRepository tagRepository; + + @Autowired + public TagTestHelper(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + public Tag initTagData() { + Tag tag = TagFixture.TAG_1.get();; + return tagRepository.save(tag); + } + + public Tag initTagData(Tag tag) { + return tagRepository.save(tag); + } +} diff --git a/backend/src/test/java/kr/touroot/tag/service/TagServiceTest.java b/backend/src/test/java/kr/touroot/tag/service/TagServiceTest.java new file mode 100644 index 00000000..5e30d5d0 --- /dev/null +++ b/backend/src/test/java/kr/touroot/tag/service/TagServiceTest.java @@ -0,0 +1,53 @@ +package kr.touroot.tag.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.ServiceTest; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.tag.domain.Tag; +import kr.touroot.tag.dto.TagCreateRequest; +import kr.touroot.tag.fixture.TagFixture; +import kr.touroot.tag.helper.TagTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("태그 서비스") +@Import(value = {TagService.class, TagTestHelper.class, TestQueryDslConfig.class}) +@ServiceTest +class TagServiceTest { + + private final DatabaseCleaner databaseCleaner; + private final TagTestHelper testHelper; + private final TagService tagService; + + @Autowired + public TagServiceTest(DatabaseCleaner databaseCleaner, TagTestHelper testHelper, TagService tagService) { + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + this.tagService = tagService; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("태그 서비스는 중복된 태그 생성 요청시 예외가 발생한다.") + @Test + void validateDuplicated() { + // given + Tag tag = TagFixture.TAG_1.get(); + testHelper.initTagData(tag); + TagCreateRequest request = new TagCreateRequest(tag.getTag()); + + // when & then + assertThatThrownBy(() -> tagService.createTag(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이미 존재하는 태그입니다."); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/controller/TravelogueControllerTest.java b/backend/src/test/java/kr/touroot/travelogue/controller/TravelogueControllerTest.java new file mode 100644 index 00000000..7876fb7c --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/controller/TravelogueControllerTest.java @@ -0,0 +1,533 @@ +package kr.touroot.travelogue.controller; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.global.exception.dto.ExceptionResponse; +import kr.touroot.image.infrastructure.AwsS3Provider; +import kr.touroot.member.domain.Member; +import kr.touroot.tag.fixture.TagFixture; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; +import kr.touroot.travelogue.dto.response.TravelogueResponse; +import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; +import kr.touroot.travelogue.fixture.TravelogueRequestFixture; +import kr.touroot.travelogue.fixture.TravelogueResponseFixture; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.utils.DatabaseCleaner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpHeaders; + +@DisplayName("여행기 컨트롤러") +@AcceptanceTest +class TravelogueControllerTest { + + private final DatabaseCleaner databaseCleaner; + private final TravelogueTestHelper testHelper; + private final ObjectMapper objectMapper; + private final JwtTokenProvider jwtTokenProvider; + @MockBean + private final AwsS3Provider s3Provider; + + @LocalServerPort + private int port; + private Member member; + private String accessToken; + + @Autowired + public TravelogueControllerTest( + DatabaseCleaner databaseCleaner, + TravelogueTestHelper testHelper, + ObjectMapper objectMapper, + JwtTokenProvider jwtTokenProvider, + AwsS3Provider s3Provider + ) { + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + this.objectMapper = objectMapper; + this.jwtTokenProvider = jwtTokenProvider; + this.s3Provider = s3Provider; + } + + @BeforeEach + void setUp() { + RestAssured.port = port; + + databaseCleaner.executeTruncate(); + + member = testHelper.initKakaoMemberTestData(); + accessToken = jwtTokenProvider.createToken(member.getId()) + .accessToken(); + } + + @DisplayName("태그가 없는 여행기를 작성한다.") + @Test + void createTravelogue() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().post("/api/v1/travelogues") + .then().log().all() + .statusCode(201) + .header("Location", "/api/v1/travelogues/1"); + } + + private List getTravelogueDayRequests() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List places = TravelogueRequestFixture.getTraveloguePlaceRequests(photos); + return TravelogueRequestFixture.getTravelogueDayRequests(places); + } + + @DisplayName("태그가 있는 여행기를 작성한다.") + @Test + void createTravelogueWithTags() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + testHelper.initTagTestData(); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days, List.of(1L)); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().post("/api/v1/travelogues") + .then().log().all() + .statusCode(201) + .header("Location", "/api/v1/travelogues/1"); + } + + @DisplayName("최대 업로드 가능 개수 이상의 사진을 포함한 여행기를 작성하면 예외가 발생한다.") + @Test + void createTravelogueWithOver10PhotosEachPlaces() throws JsonProcessingException { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + List photos = TravelogueRequestFixture.getTraveloguePhotoRequestsOverLimit(); + List places = TravelogueRequestFixture.getTraveloguePlaceRequests(photos); + List days = TravelogueRequestFixture.getTravelogueDayRequests(places); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + Member member = testHelper.initKakaoMemberTestData(); + String accessToken = jwtTokenProvider.createToken(member.getId()) + .accessToken(); + + ExceptionResponse response = new ExceptionResponse("여행기 장소 사진은 최대 10개입니다."); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().post("/api/v1/travelogues") + .then().log().all() + .statusCode(400).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("최소 여행 일자 개수를 만족하지 않은 여행기를 작성하려하면 예외가 발생한다.") + @Test + void createTravelogueWithNoDays() throws JsonProcessingException { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(List.of()); + + ExceptionResponse response = new ExceptionResponse("여행기 일자는 최소 1일은 포함되어야 합니다."); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().post("/api/v1/travelogues") + .then().log().all() + .statusCode(400).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("최소 여행 장소 개수를 만족하지 않은 여행기를 작성하려하면 예외가 발생한다.") + @Test + void createTravelogueWithNoPlacesDay() throws JsonProcessingException { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + List days = TravelogueRequestFixture.getTravelogueDayRequests(List.of()); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + ExceptionResponse response = new ExceptionResponse("여행기 장소는 최소 한 곳은 포함되어야 합니다."); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().post("/api/v1/travelogues") + .then().log().all() + .statusCode(400).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("여행기를 작성할 때 로그인 되어 있지 않으면 예외가 발생한다.") + @Test + void createTravelogueWithNotLoginThrowException() { + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/v1/travelogues") + .then().log().all() + .statusCode(401) + .body("message", is("로그인을 해주세요.")); + } + + @DisplayName("여행기에 좋아요를 한다.") + @Test + void likeTravelogue() throws JsonProcessingException { + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(author); + TravelogueLikeResponse response = new TravelogueLikeResponse(true, 1L); + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().post("/api/v1/travelogues/1/like") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("존재하지 않는 여행기에 좋아요를 하면 예외가 발생한다.") + @Test + void likeTravelogueWithNotExistThrowException() throws JsonProcessingException { + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().post("/api/v1/travelogues/1/like") + .then().log().all() + .statusCode(400).assertThat() + .body("message", is("존재하지 않는 여행기입니다.")); + } + + @DisplayName("여행기를 좋아요 할 때 로그인 되어 있지 않으면 예외가 발생한다.") + @Test + void likeTravelogueWithNotLoginThrowException() { + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(author); + + RestAssured.given().log().all() + .when().post("/api/v1/travelogues/1/like") + .then().log().all() + .statusCode(401) + .body("message", is("로그인을 해주세요.")); + } + + @DisplayName("여행기를 상세 조회한다.") + @Test + void findTravelogue() throws JsonProcessingException { + testHelper.initTravelogueTestData(member); + TravelogueResponse response = TravelogueResponseFixture.getTravelogueResponse(); + + RestAssured.given().log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/1") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("여행기에 좋아요를 누른 사용자가 여행기를 상세 조회한다.") + @Test + void findTravelogueWithLike() throws JsonProcessingException { + testHelper.initTravelogueTestDataWithLike(member); + TravelogueResponse response = TravelogueResponseFixture.getTravelogueResponseWithLike(); + + RestAssured.given().log().all() + .accept(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().get("/api/v1/travelogues/1") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("태그가 있는 여행기를 상세 조회한다.") + @Test + void findTravelogueWithTags() throws JsonProcessingException { + testHelper.initTravelogueTestDataWithTag(member); + TravelogueResponse response = TravelogueResponseFixture.getTravelogueResponseWithTag(); + + RestAssured.given().log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/1") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("메인 페이지 조회 시, 최신 작성 순으로 여행기를 조회한다.") + @Test + void findMainPageTravelogues() throws JsonProcessingException { + testHelper.initAllTravelogueTestData(); + Page responses = TravelogueResponseFixture.getTravelogueSimpleResponses(); + + RestAssured.given().log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(responses))); + } + + @DisplayName("메인 페이지 조회 시, 최신 작성 순으로 여행기를 조회한다.") + @Test + void filterMainPageTravelogues() throws JsonProcessingException { + testHelper.initAllTravelogueTestData(); + testHelper.initTravelogueTestDataWithTag(member, List.of(TagFixture.TAG_2.get(), TagFixture.TAG_3.get())); + + RestAssured.given().log().all() + .accept(ContentType.JSON) + .params("tag-filter", "2,3") + .when().get("/api/v1/travelogues") + .then().log().all() + .statusCode(200).assertThat() + .body("content.size()", is(1)); + } + + @DisplayName("존재하지 않는 여행기를 조회하면 예외가 발생한다.") + @Test + void findNotExistTravelogueThrowException() { + RestAssured.given().log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/1") + .then().log().all() + .statusCode(400).assertThat() + .body("message", is("존재하지 않는 여행기입니다.")); + } + + @DisplayName("제목 키워드를 기준으로 여행기를 조회할 수 있다.") + @Test + void findTraveloguesByTitleKeyword() throws JsonProcessingException { + testHelper.initAllTravelogueTestData(); + Page responses = TravelogueResponseFixture.getTravelogueSimpleResponses(); + + RestAssured.given().param("keyword", "제주") + .log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/search") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(responses))); + } + + @DisplayName("제목 키워드는 2글자 이상이어야 한다.") + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void findTraveloguesKeywordNotBlank(String keyword) { + testHelper.initTravelogueTestData(); + + RestAssured.given().param("keyword", keyword) + .log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/search") + .then().log().all() + .statusCode(400).assertThat() + .body("message", is("검색어는 2글자 이상이어야 합니다.")); + } + + @DisplayName("제목 키워드는 중간 공백 상관 없이 검색되어야 한다.") + @ParameterizedTest + @ValueSource(strings = {"제 주", "제주 에하영옵 서"}) + void findTraveloguesKeywordWithMiddleBlank(String keyword) throws JsonProcessingException { + testHelper.initAllTravelogueTestData(); + Page responses = TravelogueResponseFixture.getTravelogueSimpleResponses(); + + RestAssured.given().param("keyword", keyword) + .log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/search") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(responses))); + } + + @DisplayName("여행기를 수정한다.") + @Test + void updateTravelogue() throws JsonProcessingException { + Travelogue travelogue = testHelper.initTravelogueTestData(member); + + List days = getUpdateTravelogueDayRequests(); + saveImages(days); + + TravelogueRequest request = TravelogueRequestFixture.getUpdateTravelogueRequest(days); + TravelogueResponse response = TravelogueResponseFixture.getUpdatedTravelogueResponse(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().put("/api/v1/travelogues/" + travelogue.getId()) + .then().log().all() + .statusCode(200) + .body(is(objectMapper.writeValueAsString(response))); + } + + private List getUpdateTravelogueDayRequests() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List places = TravelogueRequestFixture.getUpdateTraveloguePlaceRequests(photos); + return TravelogueRequestFixture.getUpdateTravelogueDayRequests(places); + } + + private void saveImages(List days) { + when(s3Provider.copyImageToPermanentStorage( + TravelogueRequestFixture.getTravelogueRequest(days).thumbnail()) + ).thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + when(s3Provider.copyImageToPermanentStorage( + TravelogueRequestFixture.getTraveloguePhotoRequests().get(0).url()) + ).thenReturn(TravelogueResponseFixture.getTraveloguePhotoUrls().get(0)); + } + + @DisplayName("존재하지 않는 여행기를 수정 시, 400을 응답한다.") + @Test + void updateTravelogueWithNotExist() { + testHelper.initTravelogueTestData(member); + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getUpdateTravelogueRequest(days); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().put("/api/v1/travelogues/" + 0) + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 여행기입니다.")); + } + + @DisplayName("작성자가 아닌 사용자가 여행기 수정 시 403을 응답한다.") + @Test + void updateTravelogueWithNotAuthor() { + Travelogue travelogue = testHelper.initTravelogueTestData(); + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getUpdateTravelogueRequest(days); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().put("/api/v1/travelogues/" + travelogue.getId()) + .then().log().all() + .statusCode(403) + .body("message", is("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다.")); + } + + @DisplayName("여행기를 삭제한다.") + @Test + void deleteTravelogue() { + testHelper.initTravelogueTestData(member); + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travelogues/1") + .then().log().all() + .statusCode(204); + } + + @DisplayName("존재하지 않는 여행기 삭제시 400를 응답한다.") + @Test + void deleteTravelogueWithNonExist() { + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travelogues/1") + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 여행기입니다.")); + } + + @DisplayName("작성자가 아닌 사용자가 여행기 삭제시 403을 응답한다.") + @Test + void deleteTravelogueWithNotAuthor() { + Travelogue travelogue = testHelper.initTravelogueTestData(); + Member notAuthor = testHelper.initKakaoMemberTestData(); + String accessToken = jwtTokenProvider.createToken(notAuthor.getId()) + .accessToken(); + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travelogues/" + travelogue.getId()) + .then().log().all() + .statusCode(403) + .body("message", is("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다.")); + } + + @DisplayName("여행기에 좋아요 취소를 한다.") + @Test + void unlikeTravelogue() throws JsonProcessingException { + testHelper.initTravelogueTestDataWithLike(member); + TravelogueLikeResponse response = new TravelogueLikeResponse(false, 0L); + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travelogues/1/like") + .then().log().all() + .statusCode(200).assertThat() + .body(is(objectMapper.writeValueAsString(response))); + } + + @DisplayName("존재하지 않는 여행기에 좋아요 취소를 하면 예외가 발생한다.") + @Test + void unlikeTravelogueWithNotExistThrowException() throws JsonProcessingException { + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travelogues/1/like") + .then().log().all() + .statusCode(400).assertThat() + .body("message", is("존재하지 않는 여행기입니다.")); + } + + @DisplayName("여행기 좋아요 취소를 할 때 로그인 되어 있지 않으면 예외가 발생한다.") + @Test + void unlikeTravelogueWithNotLoginThrowException() { + testHelper.initTravelogueTestDataWithLike(member); + + RestAssured.given().log().all() + .when().delete("/api/v1/travelogues/1/like") + .then().log().all() + .statusCode(401) + .body("message", is("로그인을 해주세요.")); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/domain/TravelogueDayTest.java b/backend/src/test/java/kr/touroot/travelogue/domain/TravelogueDayTest.java new file mode 100644 index 00000000..946bf8d3 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/domain/TravelogueDayTest.java @@ -0,0 +1,50 @@ +package kr.touroot.travelogue.domain; + +import static kr.touroot.travelogue.fixture.TravelogueFixture.TRAVELOGUE; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행기 날짜") +class TravelogueDayTest { + + private static final Integer VALID_ORDER = 0; + private static final Travelogue VALID_TRAVELOGUE = TRAVELOGUE.get(); + + @DisplayName("유효한 여행 날짜 생성 시 예외가 발생하지 않는다") + @Test + void createTravelogueDayWithValidData() { + assertThatCode(() -> new TravelogueDay(VALID_ORDER, VALID_TRAVELOGUE)) + .doesNotThrowAnyException(); + } + + @DisplayName("여행 날짜의 순서는 비어 있을 수 없다") + @Test + void createTravelogueDayWithNullOrder() { + assertThatThrownBy(() -> new TravelogueDay(null, VALID_TRAVELOGUE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 날짜가 속한 여행기와 여행 날짜의 순서는 비어 있을 수 없습니다"); + } + + @DisplayName("여행 날짜가 속할 여행기는 비어 있을 수 없다") + @Test + void createTravelogueDayWithNullTravelogue() { + assertThatThrownBy(() -> new TravelogueDay(VALID_ORDER, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 날짜가 속한 여행기와 여행 날짜의 순서는 비어 있을 수 없습니다"); + } + + @DisplayName("여행 날짜의 순서는 음수가 될 수 없다") + @ParameterizedTest + @ValueSource(ints = {-1, -2, -3, -4, -5}) + void createTravelogueDayWithNegativeOrder(int negative) { + assertThatThrownBy(() -> new TravelogueDay(negative, VALID_TRAVELOGUE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 날짜의 순서는 음수 일 수 없습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/domain/TraveloguePhotoTest.java b/backend/src/test/java/kr/touroot/travelogue/domain/TraveloguePhotoTest.java new file mode 100644 index 00000000..354808e7 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/domain/TraveloguePhotoTest.java @@ -0,0 +1,59 @@ +package kr.touroot.travelogue.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.travelogue.fixture.TraveloguePlaceFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행 장소의 사진") +class TraveloguePhotoTest { + + private static final Integer VALID_ORDER = 0; + private static final String VALID_KEY = "valid-key"; + private static final TraveloguePlace VALID_TRAVELOGUE_PLACE = TraveloguePlaceFixture.TRAVELOGUE_PLACE.get(); + + @DisplayName("올바른 여행 장소 사진을 생성 시에는 예외가 발생하지 않는다") + @Test + void createTraveloguePhotoWithValidData() { + assertThatCode(() -> new TraveloguePhoto(VALID_ORDER, VALID_KEY, VALID_TRAVELOGUE_PLACE)) + .doesNotThrowAnyException(); + } + + @DisplayName("사진의 순서가 비어 있는 경우 사진 생성 시 예외가 발생한다") + @Test + void createTraveloguePhotoWithNullOrder() { + assertThatThrownBy(() -> new TraveloguePhoto(null, VALID_KEY, VALID_TRAVELOGUE_PLACE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 장소의 사진에서 순서와 키, 그리고 사진이 속한 여행 장소는 비어 있을 수 없습니다"); + } + + @DisplayName("사진의 장소가 비어 있는 경우 사진 생성 시 예외가 발생한다") + @Test + void createTraveloguePhotoWithNullPlace() { + assertThatThrownBy(() -> new TraveloguePhoto(VALID_ORDER, VALID_KEY, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 장소의 사진에서 순서와 키, 그리고 사진이 속한 여행 장소는 비어 있을 수 없습니다"); + } + + @DisplayName("사진의 키값이 비어 있는 경우 사진 생성 시 예외가 발생한다") + @Test + void createTraveloguePhotoWithNullKey() { + assertThatThrownBy(() -> new TraveloguePhoto(VALID_ORDER, null, VALID_TRAVELOGUE_PLACE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 장소의 사진에서 순서와 키, 그리고 사진이 속한 여행 장소는 비어 있을 수 없습니다"); + } + + @DisplayName("사진의 순서가 음수인 경우 사진 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(ints = {-1, -2, -3, -4, -5}) + void createTraveloguePhotoWithNegativeOrder(int negative) { + assertThatThrownBy(() -> new TraveloguePhoto(negative, VALID_KEY, VALID_TRAVELOGUE_PLACE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 장소에 속하는 사진의 순서는 음수가 될 수 없습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/domain/TraveloguePlaceTest.java b/backend/src/test/java/kr/touroot/travelogue/domain/TraveloguePlaceTest.java new file mode 100644 index 00000000..7ade1d18 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/domain/TraveloguePlaceTest.java @@ -0,0 +1,80 @@ +package kr.touroot.travelogue.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.place.domain.Place; +import kr.touroot.place.fixture.PlaceFixture; +import kr.touroot.travelogue.fixture.TravelogueDayFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행기 장소") +class TraveloguePlaceTest { + + private static final Integer VALID_ORDER = 0; + private static final Place VALID_PLACE = PlaceFixture.PLACE.get(); + private static final TravelogueDay VALID_DAY = TravelogueDayFixture.TRAVELOGUE_DAY.get(); + private static final String VALID_DESCRIPTION = "장소에 대한 설명"; + + @DisplayName("올바른 여행기 장소 생성 시 예외가 발생하지 않는다") + @Test + void createTraveloguePlaceWithValidData() { + assertThatCode(() -> new TraveloguePlace(VALID_ORDER, VALID_DESCRIPTION, VALID_PLACE, VALID_DAY)) + .doesNotThrowAnyException(); + } + + @DisplayName("여행기 장소 생성 시 순서가 비어 있다면 예외가 발생한다") + @Test + void createTraveloguePlaceWithNullOrder() { + assertThatThrownBy(() -> new TraveloguePlace(null, VALID_DESCRIPTION, VALID_PLACE, VALID_DAY)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행기 장소에서 순서와 장소 상세 정보, 그리고 방문 날짜는 비어 있을 수 없습니다"); + } + + @DisplayName("여행기 장소 생성 시 장소 상세 정보가 비어 있다면 예외가 발생한다") + @Test + void createTraveloguePlaceWithNullPlace() { + assertThatThrownBy(() -> new TraveloguePlace(VALID_ORDER, VALID_DESCRIPTION, null, VALID_DAY)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행기 장소에서 순서와 장소 상세 정보, 그리고 방문 날짜는 비어 있을 수 없습니다"); + } + + @DisplayName("여행기 장소 생성 시 장소가 속한 날짜가 비어 있다면 예외가 발생한다") + @Test + void createTraveloguePlaceWithNullDay() { + assertThatThrownBy(() -> new TraveloguePlace(VALID_ORDER, VALID_DESCRIPTION, VALID_PLACE, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행기 장소에서 순서와 장소 상세 정보, 그리고 방문 날짜는 비어 있을 수 없습니다"); + } + + @DisplayName("여행기 장소 생성 시 장소 설명이 비어 있더라도 여행기를 생성할 수 있다") + @Test + void createTraveloguePlaceWithNullDescription() { + assertThatCode(() -> new TraveloguePlace(VALID_ORDER, null, VALID_PLACE, VALID_DAY)) + .doesNotThrowAnyException(); + } + + @DisplayName("여행기 장소의 순서는 음수 일 수 없다") + @ParameterizedTest + @ValueSource(ints = {-1, -2, -3, -4, -5}) + void createTraveloguePlaceWithNegativeOrder(int negative) { + assertThatThrownBy(() -> new TraveloguePlace(negative, VALID_DESCRIPTION, VALID_PLACE, VALID_DAY)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 장소의 순서는 음수일 수 없습니다"); + } + + @DisplayName("여행기 장소 설명의 길이가 300자가 넘는 경우 여행기 생성 시 예외가 발생한다") + @Test + void createTraveloguePlaceWithInvalidLengthDescription() { + String length301Description = "서울의 명동은 현대와 전통이 조화롭게 어우러진 매력적인 지역입니다. 이곳의 거리에는 최신 패션 아이템을 갖춘 상점들이 즐비하며, 각종 뷰티 제품을 직접 체험할 수 있는 기회가 많습니다. 다양한 길거리 음식과 맛집이 가득해 미식가들의 입맛을 사로잡습니다. 서울타워와 N서울타워 전망대에서는 서울 전경을 한눈에 감상할 수 있으며, 남산 공원에서는 도심 속의 자연을 즐길 수 있습니다. 전통 시장인 남대문 시장과 청계천은 서울의 풍부한 역사와 문화를 체험할 수 있는 명소입니다. 명동의 활기 넘치는 분위기 속에서 쇼핑과 먹거리를 동시에 즐겨요!"; + + System.out.println("length301Description = " + length301Description.length()); + assertThatThrownBy(() -> new TraveloguePlace(VALID_ORDER, length301Description, VALID_PLACE, VALID_DAY)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 장소에 대한 설명은 300자를 넘길 수 없습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/domain/TravelogueTest.java b/backend/src/test/java/kr/touroot/travelogue/domain/TravelogueTest.java new file mode 100644 index 00000000..910335e4 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/domain/TravelogueTest.java @@ -0,0 +1,103 @@ +package kr.touroot.travelogue.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행기 도메인") +class TravelogueTest { + + private static final Member VALID_AUTHOR = MemberFixture.KAKAO_MEMBER.build(); + private static final String VALID_TITLE = "올바른 여행기 제목"; + private static final String VALID_THUMBNAIL = "http://valid-thumbnail.com"; + private static final String UPDATED_TITLE = "수정된 여행기 제목"; + private static final String UPDATED_THUMBNAIL = "http://updated-thumbnail.com"; + + @DisplayName("여행기를 수정한다.") + @Test + void update() { + Travelogue travelogue = new Travelogue(VALID_AUTHOR, VALID_TITLE, VALID_THUMBNAIL); + travelogue.update(UPDATED_TITLE, UPDATED_THUMBNAIL); + + assertAll( + () -> assertThat(travelogue.getTitle()).isEqualTo(UPDATED_TITLE), + () -> assertThat(travelogue.getThumbnail()).isEqualTo(UPDATED_THUMBNAIL) + ); + } + + @DisplayName("검증 규칙에 어긋나지 않는 여행기 생성 시 예외가 발생하지 않는다") + @Test + void createTravelogueWithValidData() { + assertThatCode(() -> new Travelogue(VALID_AUTHOR, VALID_TITLE, VALID_THUMBNAIL)) + .doesNotThrowAnyException(); + } + + @DisplayName("여행기 제목이 null인 경우 여행기 생성 시 예외가 발생한다") + @Test + void createTravelogueWithNullTitle() { + assertThatThrownBy(() -> new Travelogue(VALID_AUTHOR, null, VALID_THUMBNAIL)) + .isInstanceOf(BadRequestException.class) + .hasMessage("작성자, 여행기 제목, 그리고 여행기 썸네일은 비어있을 수 없습니다"); + } + + @DisplayName("여행기 작성자가 null인 경우 여행기 생성 시 예외가 발생한다") + @Test + void createTravelogueWithNullAuthor() { + assertThatThrownBy(() -> new Travelogue(null, VALID_TITLE, VALID_THUMBNAIL)) + .isInstanceOf(BadRequestException.class) + .hasMessage("작성자, 여행기 제목, 그리고 여행기 썸네일은 비어있을 수 없습니다"); + } + + @DisplayName("여행기 썸네일이 null인 경우 여행기 생성 시 예외가 발생한다") + @Test + void createTravelogueWithNullThumbNail() { + assertThatThrownBy(() -> new Travelogue(VALID_AUTHOR, VALID_TITLE, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage("작성자, 여행기 제목, 그리고 여행기 썸네일은 비어있을 수 없습니다"); + } + + @DisplayName("여행기 제목이 비어 있는 경우 여행기 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"", " ", " "}) + void createTravelogueWithBlankTitle(String blank) { + assertThatThrownBy(() -> new Travelogue(VALID_AUTHOR, blank, VALID_THUMBNAIL)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행기 제목, 여행기 썸네일은 비어있을 수 없습니다"); + } + + @DisplayName("여행기 썸네일이 비어 있는 경우 여행기 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"", " ", " "}) + void createTravelogueWithBlankThumbNail(String blank) { + assertThatThrownBy(() -> new Travelogue(VALID_AUTHOR, VALID_TITLE, blank)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행기 제목, 여행기 썸네일은 비어있을 수 없습니다"); + } + + @DisplayName("여행기 제목의 길이가 1자 이상 20자 이하가 아닌 경우 여행기 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"21-length-stringggggg", "22-length-stringgggggg", "23-length-stringggggggg"}) + void createTravelogueWithInvalidLengthTitle(String invalidLengthTitle) { + assertThatThrownBy(() -> new Travelogue(VALID_AUTHOR, invalidLengthTitle, VALID_THUMBNAIL)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행기 제목은 1자 이상, 20자 이하여야 합니다"); + } + + @DisplayName("여행기 썸네일 경로가 URL형식을 벗어나는 경우 여행기 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"ht://touroot.com/images/1", "touroot.com/images/1"}) + void createTravelogueWithInvalidThumbnail(String invalidThumbnail) { + assertThatThrownBy(() -> new Travelogue(VALID_AUTHOR, VALID_TITLE, invalidThumbnail)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이미지 url 형식이 잘못되었습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueDayFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueDayFixture.java new file mode 100644 index 00000000..116ebcc3 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueDayFixture.java @@ -0,0 +1,24 @@ +package kr.touroot.travelogue.fixture; + +import static kr.touroot.travelogue.fixture.TravelogueFixture.TRAVELOGUE; + +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TravelogueDayFixture { + + TRAVELOGUE_DAY(1, TRAVELOGUE.get()); + + private final int order; + private final Travelogue travelogue; + + public TravelogueDay get() { + return new TravelogueDay(order, travelogue); + } + + public TravelogueDay create(int order, Travelogue travelogue) { + return new TravelogueDay(order, travelogue); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueFixture.java new file mode 100644 index 00000000..bc356c8f --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueFixture.java @@ -0,0 +1,25 @@ +package kr.touroot.travelogue.fixture; + + +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import kr.touroot.travelogue.domain.Travelogue; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TravelogueFixture { + + TRAVELOGUE(MemberFixture.KAKAO_MEMBER.build(), "제주에 하영 옵서", "https://dev.touroot.kr/temporary/jeju_thumbnail.png"); + + private final Member author; + private final String title; + private final String thumbnail; + + public Travelogue get() { + return new Travelogue(author, title, thumbnail); + } + + public Travelogue create(Member author) { + return new Travelogue(author, title, thumbnail); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TraveloguePhotoFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TraveloguePhotoFixture.java new file mode 100644 index 00000000..c587cc11 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TraveloguePhotoFixture.java @@ -0,0 +1,25 @@ +package kr.touroot.travelogue.fixture; + +import static kr.touroot.travelogue.fixture.TraveloguePlaceFixture.TRAVELOGUE_PLACE; + +import kr.touroot.travelogue.domain.TraveloguePhoto; +import kr.touroot.travelogue.domain.TraveloguePlace; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TraveloguePhotoFixture { + + TRAVELOGUE_PHOTO(1, "https://dev.touroot.kr/temporary/image1.png", TRAVELOGUE_PLACE.get()); + + private final int order; + private final String url; + private final TraveloguePlace place; + + public TraveloguePhoto get() { + return new TraveloguePhoto(order, url, place); + } + + public TraveloguePhoto create(TraveloguePlace place) { + return new TraveloguePhoto(order, url, place); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TraveloguePlaceFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TraveloguePlaceFixture.java new file mode 100644 index 00000000..428632e8 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TraveloguePlaceFixture.java @@ -0,0 +1,28 @@ +package kr.touroot.travelogue.fixture; + +import static kr.touroot.place.fixture.PlaceFixture.PLACE; +import static kr.touroot.travelogue.fixture.TravelogueDayFixture.TRAVELOGUE_DAY; + +import kr.touroot.place.domain.Place; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePlace; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TraveloguePlaceFixture { + + TRAVELOGUE_PLACE(1, "에메랄드 빛 해변", PLACE.get(), TRAVELOGUE_DAY.get()); + + private final int order; + private final String description; + private final Place place; + private final TravelogueDay day; + + public TraveloguePlace get() { + return new TraveloguePlace(order, description, place, day); + } + + public TraveloguePlace create(Place place, TravelogueDay day) { + return new TraveloguePlace(order, description, place, day); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java new file mode 100644 index 00000000..e4987c74 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java @@ -0,0 +1,93 @@ +package kr.touroot.travelogue.fixture; + +import java.util.List; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.dto.request.TraveloguePositionRequest; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import org.springframework.stereotype.Component; + +@Component +public class TravelogueRequestFixture { + + private TravelogueRequestFixture() { + } + + public static TravelogueRequest getTravelogueRequest(List days) { + return new TravelogueRequest( + "제주에 하영 옵서", + "https://dev.touroot.kr/temporary/jeju_thumbnail.png", + List.of(), + days + ); + } + + public static TravelogueRequest getTravelogueRequest(List days, List tags) { + return new TravelogueRequest( + "제주에 하영 옵서", + "https://dev.touroot.kr/temporary/jeju_thumbnail.png", + tags, + days + ); + } + + public static TravelogueRequest getUpdateTravelogueRequest(List days) { + return new TravelogueRequest( + "삼춘! 제주에 하영 옵서!", + "https://dev.touroot.kr/temporary/jeju_thumbnail_2.png", + List.of(), + days + ); + } + + public static List getTravelogueDayRequests(List places) { + return List.of(new TravelogueDayRequest(places)); + } + + public static List getUpdateTravelogueDayRequests(List places) { + return List.of(new TravelogueDayRequest(places), new TravelogueDayRequest(places)); + } + + public static List getTraveloguePlaceRequests(List photos) { + return List.of(new TraveloguePlaceRequest( + "함덕해수욕장", + getTraveloguePositionRequest(), + "에메랄드 빛 해변", + photos + )); + } + + public static List getUpdateTraveloguePlaceRequests(List photos) { + return List.of(new TraveloguePlaceRequest( + "함덕해수욕장", + getTraveloguePositionRequest(), + "에메랄드 빛 해변은 해외 휴양지와 견줘도 밀리지 않습니다.", + photos + )); + } + + public static TraveloguePositionRequest getTraveloguePositionRequest() { + return new TraveloguePositionRequest("34.54343", "126.66977"); + } + + public static List getTraveloguePhotoRequests() { + return List.of(new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image1.png")); + } + + public static List getTraveloguePhotoRequestsOverLimit() { + return List.of( + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image1.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image2.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image3.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image4.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image5.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image6.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image7.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image8.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image9.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image10.png"), + new TraveloguePhotoRequest("https://dev.touroot.kr/temporary/image11.png") + ); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueResponseFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueResponseFixture.java new file mode 100644 index 00000000..369e3503 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueResponseFixture.java @@ -0,0 +1,177 @@ +package kr.touroot.travelogue.fixture; + +import java.time.LocalDate; +import java.util.List; +import kr.touroot.tag.fixture.TagFixture; +import kr.touroot.travelogue.dto.response.TravelogueDayResponse; +import kr.touroot.travelogue.dto.response.TraveloguePlaceResponse; +import kr.touroot.travelogue.dto.response.TraveloguePositionResponse; +import kr.touroot.travelogue.dto.response.TravelogueResponse; +import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Component; + +@Component +public class TravelogueResponseFixture { + + private TravelogueResponseFixture() { + } + + public static TravelogueResponse getTravelogueResponse() { + return TravelogueResponse.builder() + .id(1L) + .title("제주에 하영 옵서") + .createdAt(LocalDate.now()) + .authorNickname("리비") + .authorId(1L) + .authorProfileImageUrl("https://dev.touroot.kr/temporary/profile.png") + .thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail.png") + .days(getTravelogueDayResponses()) + .tags(List.of()) + .isLiked(false) + .likeCount(0L) + .build(); + } + + public static TravelogueResponse getUpdatedTravelogueResponse() { + return TravelogueResponse.builder() + .id(1L) + .title("삼춘! 제주에 하영 옵서!") + .createdAt(LocalDate.now()) + .authorNickname("리비") + .authorId(1L) + .authorProfileImageUrl("https://dev.touroot.kr/temporary/profile.png") + .thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail_2.png") + .days(getUpdatedTravelogueDayResponses()) + .tags(List.of()) + .isLiked(false) + .likeCount(0L) + .build(); + } + + public static TravelogueResponse getTravelogueResponseWithTag() { + return TravelogueResponse.builder() + .id(1L) + .title("제주에 하영 옵서") + .createdAt(LocalDate.now()) + .authorNickname("리비") + .authorId(1L) + .authorProfileImageUrl("https://dev.touroot.kr/temporary/profile.png") + .thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail.png") + .days(getTravelogueDayResponses()) + .tags(List.of(TagFixture.TAG_1.getResponse(1L))) + .likeCount(0L) + .isLiked(false) + .build(); + } + + public static TravelogueResponse getTravelogueResponseWithLike() { + return TravelogueResponse.builder() + .id(1L) + .title("제주에 하영 옵서") + .createdAt(LocalDate.now()) + .authorNickname("리비") + .authorId(2L) + .authorProfileImageUrl("https://dev.touroot.kr/temporary/profile.png") + .thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail.png") + .days(getTravelogueDayResponses()) + .tags(List.of()) + .isLiked(true) + .likeCount(1L) + .build(); + } + + public static Page getTravelogueSimpleResponses() { + List responses = List.of( + TravelogueSimpleResponse.builder() + .id(2L) + .title("제주에 하영 옵서") + .authorNickname("리비") + .authorProfileUrl("https://dev.touroot.kr/temporary/profile.png") + .thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail.png") + .tags(List.of(TagFixture.TAG_1.getResponse(1L))) + .likeCount(0L) + .build(), + TravelogueSimpleResponse.builder() + .id(1L) + .title("제주에 하영 옵서") + .authorNickname("리비") + .authorProfileUrl("https://dev.touroot.kr/temporary/profile.png") + .thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail.png") + .tags(List.of()) + .likeCount(1L) + .build() + ); + + return new PageImpl<>(responses, PageRequest.of(0, 5, Sort.by(Direction.DESC, "id")), responses.size()); + } + + public static List getTravelogueDayResponses() { + return List.of(TravelogueDayResponse.builder() + .id(1L) + .places(getTraveloguePlaceResponses()) + .build() + ); + } + + public static List getUpdatedTravelogueDayResponses() { + return List.of(TravelogueDayResponse.builder() + .id(2L) + .places(getUpdatedTraveloguePlaceResponses()) + .build(), + TravelogueDayResponse.builder() + .id(3L) + .places(getAddedTraveloguePlaceResponsesWhenUpdate()) + .build() + ); + } + + public static List getTraveloguePlaceResponses() { + return List.of(TraveloguePlaceResponse.builder() + .id(1L) + .placeName("함덕해수욕장") + .description("에메랄드 빛 해변") + .position(getTraveloguePositionResponse()) + .photoUrls(getTraveloguePhotoUrls()) + .build() + ); + } + + public static List getUpdatedTraveloguePlaceResponses() { + return List.of(TraveloguePlaceResponse.builder() + .id(2L) + .placeName("함덕해수욕장") + .description("에메랄드 빛 해변은 해외 휴양지와 견줘도 밀리지 않습니다.") + .position(getTraveloguePositionResponse()) + .photoUrls(getTraveloguePhotoUrls()) + .build() + ); + } + + public static List getAddedTraveloguePlaceResponsesWhenUpdate() { + return List.of(TraveloguePlaceResponse.builder() + .id(3L) + .placeName("함덕해수욕장") + .description("에메랄드 빛 해변은 해외 휴양지와 견줘도 밀리지 않습니다.") + .position(getTraveloguePositionResponse()) + .photoUrls(getTraveloguePhotoUrls()) + .build() + ); + } + + public static TraveloguePositionResponse getTraveloguePositionResponse() { + return TraveloguePositionResponse.builder() + .id(1L) + .lat("34.54343") + .lng("126.66977") + .build(); + } + + public static List getTraveloguePhotoUrls() { + return List.of("https://dev.touroot.kr/temporary/image1.png"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java b/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java new file mode 100644 index 00000000..b8b6c074 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java @@ -0,0 +1,199 @@ +package kr.touroot.travelogue.helper; + +import static kr.touroot.place.fixture.PlaceFixture.PLACE; +import static kr.touroot.travelogue.fixture.TravelogueDayFixture.TRAVELOGUE_DAY; +import static kr.touroot.travelogue.fixture.TravelogueFixture.TRAVELOGUE; +import static kr.touroot.travelogue.fixture.TraveloguePhotoFixture.TRAVELOGUE_PHOTO; +import static kr.touroot.travelogue.fixture.TraveloguePlaceFixture.TRAVELOGUE_PLACE; + +import java.util.List; +import kr.touroot.member.domain.LoginType; +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import kr.touroot.member.repository.MemberRepository; +import kr.touroot.place.domain.Place; +import kr.touroot.place.repository.PlaceRepository; +import kr.touroot.tag.domain.Tag; +import kr.touroot.tag.fixture.TagFixture; +import kr.touroot.tag.repository.TagRepository; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TravelogueLike; +import kr.touroot.travelogue.domain.TraveloguePhoto; +import kr.touroot.travelogue.domain.TraveloguePlace; +import kr.touroot.travelogue.domain.TravelogueTag; +import kr.touroot.travelogue.repository.TravelogueDayRepository; +import kr.touroot.travelogue.repository.TravelogueLikeRepository; +import kr.touroot.travelogue.repository.TraveloguePhotoRepository; +import kr.touroot.travelogue.repository.TraveloguePlaceRepository; +import kr.touroot.travelogue.repository.TravelogueRepository; +import kr.touroot.travelogue.repository.TravelogueTagRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TravelogueTestHelper { + + private final PlaceRepository placeRepository; + private final TravelogueRepository travelogueRepository; + private final TravelogueDayRepository travelogueDayRepository; + private final TraveloguePlaceRepository traveloguePlaceRepository; + private final TraveloguePhotoRepository traveloguePhotoRepository; + private final MemberRepository memberRepository; + private final TagRepository tagRepository; + private final TravelogueTagRepository travelogueTagRepository; + private final TravelogueLikeRepository travelogueLikeRepository; + + @Autowired + public TravelogueTestHelper( + PlaceRepository placeRepository, + TravelogueRepository travelogueRepository, + TravelogueDayRepository travelogueDayRepository, + TraveloguePlaceRepository traveloguePlaceRepository, + TraveloguePhotoRepository traveloguePhotoRepository, + MemberRepository memberRepository, + TagRepository tagRepository, + TravelogueTagRepository travelogueTagRepository, + TravelogueLikeRepository travelogueLikeRepository + ) { + this.placeRepository = placeRepository; + this.travelogueRepository = travelogueRepository; + this.travelogueDayRepository = travelogueDayRepository; + this.traveloguePlaceRepository = traveloguePlaceRepository; + this.traveloguePhotoRepository = traveloguePhotoRepository; + this.memberRepository = memberRepository; + this.tagRepository = tagRepository; + this.travelogueTagRepository = travelogueTagRepository; + this.travelogueLikeRepository = travelogueLikeRepository; + } + + public void initAllTravelogueTestData() { + Member author = persistMember(); + Travelogue travelogue = initTravelogueTestData(author); + initTravelogueTestDataWithTag(author); + persistTravelogueLike(travelogue, author); + } + + public Travelogue initTravelogueTestData() { + Member author = persistMember(); + return initTravelogueTestData(author); + } + + public Travelogue initTravelogueTestDataWithSeveralDays() { + Member author = persistMember(); + return initTravelogueTestDataWithSeveralDays(author); + } + + public Travelogue initTravelogueTestData(Member author) { + Travelogue travelogue = persistTravelogue(author); + TravelogueDay day = persistTravelogueDay(travelogue); + Place position = persistPlace(); + TraveloguePlace place = persistTraveloguePlace(position, day); + persistTraveloguePhoto(place); + + return travelogue; + } + + public Travelogue initTravelogueTestDataWithSeveralDays(Member author) { + Travelogue travelogue = persistTravelogue(author); + List days = List.of(persistTravelogueDay(travelogue), persistTravelogueDay(travelogue)); + Place position = persistPlace(); + + days.stream() + .map(day -> persistTraveloguePlace(position, day)) + .map(this::persistTraveloguePhoto) + .toList(); + + return travelogue; + } + + public Travelogue initTravelogueTestDataWithTag(Member author) { + Travelogue travelogue = persistTravelogue(author); + TravelogueDay day = persistTravelogueDay(travelogue); + Place position = persistPlace(); + TraveloguePlace place = persistTraveloguePlace(position, day); + persistTraveloguePhoto(place); + persisTravelogueTag(travelogue, TagFixture.TAG_1.get()); + + return travelogue; + } + + public Travelogue initTravelogueTestDataWithTag(Member author, List tags) { + Travelogue travelogue = persistTravelogue(author); + TravelogueDay day = persistTravelogueDay(travelogue); + Place position = persistPlace(); + TraveloguePlace place = persistTraveloguePlace(position, day); + persistTraveloguePhoto(place); + + tags.forEach(tag -> persisTravelogueTag(travelogue, tag)); + + return travelogue; + } + + public Travelogue initTravelogueTestDataWithLike(Member liker) { + Travelogue travelogue = initTravelogueTestData(); + persistTravelogueLike(travelogue, liker); + + return travelogue; + } + + private void persisTravelogueTag(Travelogue travelogue, Tag tag) { + Tag savedTag = initTagTestData(tag); + travelogueTagRepository.save(new TravelogueTag(travelogue, savedTag)); + } + + public Member persistMember() { + Member author = MemberFixture.KAKAO_MEMBER.build(); + + return memberRepository.save(author); + } + + public Travelogue persistTravelogue(Member author) { + Travelogue travelogue = TRAVELOGUE.create(author); + + return travelogueRepository.save(travelogue); + } + + public TravelogueDay persistTravelogueDay(Travelogue travelogue) { + TravelogueDay day = TRAVELOGUE_DAY.create(1, travelogue); + + return travelogueDayRepository.save(day); + } + + public Place persistPlace() { + Place place = PLACE.get(); + + return placeRepository.save(place); + } + + public TraveloguePlace persistTraveloguePlace(Place position, TravelogueDay day) { + TraveloguePlace place = TRAVELOGUE_PLACE.create(position, day); + + return traveloguePlaceRepository.save(place); + } + + public TraveloguePhoto persistTraveloguePhoto(TraveloguePlace place) { + TraveloguePhoto photo = TRAVELOGUE_PHOTO.create(place); + + return traveloguePhotoRepository.save(photo); + } + + public TravelogueLike persistTravelogueLike(Travelogue travelogue, Member liker) { + TravelogueLike like = new TravelogueLike(travelogue, liker); + + return travelogueLikeRepository.save(like); + } + + public Member initKakaoMemberTestData() { + Member member = new Member(1L, "리비", "https://dev.touroot.kr/temporary/profile.png", LoginType.KAKAO); + return memberRepository.save(member); + } + + public Tag initTagTestData() { + return tagRepository.save(TagFixture.TAG_1.get()); + } + + public Tag initTagTestData(Tag tag) { + return tagRepository.save(tag); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueDayServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueDayServiceTest.java new file mode 100644 index 00000000..80b41d57 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueDayServiceTest.java @@ -0,0 +1,119 @@ +package kr.touroot.travelogue.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.util.List; +import java.util.Map; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.fixture.TravelogueRequestFixture; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.travelogue.repository.TravelogueDayRepository; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("여행기 일자 서비스") +@Import(value = {TravelogueDayService.class, TravelogueTestHelper.class, TestQueryDslConfig.class}) +@ServiceTest +class TravelogueDayServiceTest { + + private final TravelogueDayService dayService; + private final TravelogueDayRepository dayRepository; + private final DatabaseCleaner databaseCleaner; + private final TravelogueTestHelper testHelper; + + @Autowired + public TravelogueDayServiceTest( + TravelogueDayService dayService, + TravelogueDayRepository dayRepository, + DatabaseCleaner databaseCleaner, + TravelogueTestHelper testHelper + ) { + this.dayService = dayService; + this.dayRepository = dayRepository; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("여행기의 일자들을 생성한다.") + @Test + void createDays() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List places = TravelogueRequestFixture.getTraveloguePlaceRequests(photos); + List requests = TravelogueRequestFixture.getTravelogueDayRequests(places); + Member author = testHelper.persistMember(); + Travelogue travelogue = testHelper.persistTravelogue(author); + + Map> daysMap = dayService.createDays(requests, travelogue); + List days = daysMap.keySet().stream().toList(); + + assertAll( + () -> assertThat(daysMap.keySet()).hasSize(requests.size()), + () -> assertThat(daysMap).containsEntry(days.get(0), requests.get(0).places()) + ); + } + + @DisplayName("여행기를 기준으로 여행 일자들을 조회한다.") + @Test + void findDaysByTravelogue() { + Member author = testHelper.persistMember(); + Travelogue travelogue = testHelper.persistTravelogue(author); + TravelogueDay travelogueDay = testHelper.persistTravelogueDay(travelogue); + + List days = dayService.findDaysByTravelogue(travelogue); + + assertThat(days).contains(travelogueDay); + } + + @DisplayName("여행 일자를 ID를 기준으로 조회한다.") + @Test + void findDayById() { + testHelper.initTravelogueTestData(); + + assertDoesNotThrow(() -> dayService.findDayById(1L)); + } + + @DisplayName("존재하지 않는 여행기 일자 ID로 조회하면 예외가 발생한다.") + @Test + void findDayByInvalidIdThrowException() { + assertThatThrownBy(() -> dayService.findDayById(1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기 일자입니다."); + } + + @DisplayName("주어진 여행기의 여행기 일자를 삭제할 수 있다.") + @Test + void deleteTravelogueDayById() { + Travelogue travelogue = testHelper.initTravelogueTestDataWithSeveralDays(); + long travelogueId = travelogue.getId(); + dayService.deleteAllByTravelogue(travelogue); + + assertThat(dayRepository.findAll() + .stream() + .noneMatch(day -> extractTravelogue(day).getId() == travelogueId)) + .isTrue(); + } + + private Travelogue extractTravelogue(TravelogueDay day) { + return day.getTravelogue(); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java new file mode 100644 index 00000000..37889a46 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java @@ -0,0 +1,296 @@ +package kr.touroot.travelogue.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.touroot.authentication.infrastructure.PasswordEncryptor; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.image.infrastructure.AwsS3Provider; +import kr.touroot.member.domain.Member; +import kr.touroot.member.service.MemberService; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.dto.request.TravelogueSearchRequest; +import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; +import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; +import kr.touroot.travelogue.fixture.TravelogueRequestFixture; +import kr.touroot.travelogue.fixture.TravelogueResponseFixture; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +@DisplayName("여행기 Facade 서비스") +@Import(value = { + TravelogueFacadeService.class, + TravelogueService.class, + TraveloguePhotoService.class, + TravelogueDayService.class, + TraveloguePlaceService.class, + TravelogueTagService.class, + TravelogueLikeService.class, + MemberService.class, + TravelogueTestHelper.class, + AwsS3Provider.class, + PasswordEncryptor.class, + TestQueryDslConfig.class +}) +@ServiceTest +class TravelogueFacadeServiceTest { + + private final TravelogueFacadeService service; + private final TravelogueTestHelper testHelper; + private final DatabaseCleaner databaseCleaner; + @MockBean + private final AwsS3Provider s3Provider; + + @Autowired + public TravelogueFacadeServiceTest( + TravelogueFacadeService travelogueFacadeService, + TravelogueTestHelper travelogueTestHelper, + DatabaseCleaner databaseCleaner, + AwsS3Provider s3Provider + ) { + this.service = travelogueFacadeService; + this.testHelper = travelogueTestHelper; + this.databaseCleaner = databaseCleaner; + this.s3Provider = s3Provider; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("여행기를 생성할 수 있다.") + @Test + void createTravelogue() { + List days = getTravelogueDayRequests(); + saveImages(days); + + testHelper.initKakaoMemberTestData(); + MemberAuth memberAuth = new MemberAuth(1L); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + assertThat(service.createTravelogue(memberAuth, request)) + .isEqualTo(TravelogueResponseFixture.getTravelogueResponse()); + } + + private void saveImages(List days) { + when(s3Provider.copyImageToPermanentStorage( + TravelogueRequestFixture.getTravelogueRequest(days).thumbnail()) + ).thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + when(s3Provider.copyImageToPermanentStorage( + TravelogueRequestFixture.getTraveloguePhotoRequests().get(0).url()) + ).thenReturn(TravelogueResponseFixture.getTraveloguePhotoUrls().get(0)); + } + + private List getTravelogueDayRequests() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List places = TravelogueRequestFixture.getTraveloguePlaceRequests(photos); + return TravelogueRequestFixture.getTravelogueDayRequests(places); + } + + @DisplayName("여행기에 좋아요를 할 수 있다.") + @Test + void likeTravelogue() { + Travelogue travelogue = testHelper.initTravelogueTestData(); + Member liker = testHelper.initKakaoMemberTestData(); + + assertThat(service.likeTravelogue(travelogue.getId(), new MemberAuth(liker.getId()))) + .isEqualTo(new TravelogueLikeResponse(true, 1L)); + } + + @DisplayName("존재하지 않는 여행기에 좋아요를 하면 예외가 발생한다.") + @Test + void likeTravelogueWithNotExist() { + Member liker = testHelper.initKakaoMemberTestData(); + + assertThatThrownBy(() -> service.likeTravelogue(1L, new MemberAuth(liker.getId()))) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("여행기를 ID를 기준으로 조회한다.") + @Test + void findTravelogueById() { + testHelper.initTravelogueTestData(); + + assertThat(service.findTravelogueById(1L)) + .isEqualTo(TravelogueResponseFixture.getTravelogueResponse()); + } + + @DisplayName("여행기를 ID와 로그인한 사용자를 기준으로 조회한다.") + @Test + void findTravelogueByIdAndLiker() { + Member liker = testHelper.initKakaoMemberTestData(); + Long travelogueId = testHelper.initTravelogueTestDataWithLike(liker).getId(); + + assertThat(service.findTravelogueById(travelogueId, new MemberAuth(liker.getId()))) + .isEqualTo(TravelogueResponseFixture.getTravelogueResponseWithLike()); + } + + @DisplayName("메인 페이지에 표시할 여행기 목록을 조회한다.") + @Test + void findTravelogues() { + testHelper.initAllTravelogueTestData(); + Page expect = TravelogueResponseFixture.getTravelogueSimpleResponses(); + + PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id")); + Page result = service.findSimpleTravelogues(pageRequest); + + assertThat(result).containsAll(expect); + } + + @DisplayName("필터링된 여행기 목록을 조회한다.") + @Test + void filterTravelogues() { + // given + testHelper.initAllTravelogueTestData(); + PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id")); + List tagFilters = List.of(1L); + + // when + Page result = service.findSimpleTravelogues(tagFilters, pageRequest); + + // then + assertThat(result.getContent()).hasSize(1); + } + + @DisplayName("제목 키워드를 기반으로 여행기 목록을 조회한다.") + @Test + void findTraveloguesByKeyword() { + testHelper.initAllTravelogueTestData(); + Page responses = TravelogueResponseFixture.getTravelogueSimpleResponses(); + + TravelogueSearchRequest searchRequest = new TravelogueSearchRequest("제주"); + PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id")); + Page searchResults = service.findSimpleTravelogues(pageRequest, searchRequest); + + assertThat(searchResults).containsAll(responses); + } + + @DisplayName("여행기를 수정할 수 있다.") + @Test + void updateTravelogue() { + List days = getUpdateTravelogueDayRequests(); + saveImages(days); + + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(author); + + MemberAuth memberAuth = new MemberAuth(author.getId()); + TravelogueRequest request = TravelogueRequestFixture.getUpdateTravelogueRequest(days); + + assertThat(service.updateTravelogue(1L, memberAuth, request)) + .isEqualTo(TravelogueResponseFixture.getUpdatedTravelogueResponse()); + } + + private List getUpdateTravelogueDayRequests() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List places = TravelogueRequestFixture.getUpdateTraveloguePlaceRequests(photos); + return TravelogueRequestFixture.getUpdateTravelogueDayRequests(places); + } + + @DisplayName("존재하지 않는 여행기를 수정하면 예외가 발생한다.") + @Test + void updateTravelogueWithNotExist() { + List days = getUpdateTravelogueDayRequests(); + saveImages(days); + + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(author); + + MemberAuth memberAuth = new MemberAuth(author.getId()); + TravelogueRequest request = TravelogueRequestFixture.getUpdateTravelogueRequest(days); + + assertThatThrownBy(() -> service.updateTravelogue(0L, memberAuth, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("작성자가 아닌 사용자가 여행기를 수정하면 예외가 발생한다.") + @Test + void updateByIdWithNotAuthor() { + testHelper.initTravelogueTestData(); + MemberAuth notAuthorAuth = new MemberAuth(testHelper.initKakaoMemberTestData().getId()); + + List days = getTravelogueDayRequests(); + saveImages(days); + + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + assertThatThrownBy(() -> service.updateTravelogue(1L, notAuthorAuth, request)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다."); + } + + @DisplayName("여행기를 ID를 기준으로 삭제한다.") + @Test + void deleteById() { + testHelper.initTravelogueTestData(); + MemberAuth memberAuth = new MemberAuth(1L); + service.deleteTravelogueById(1L, memberAuth); + + assertThatThrownBy(() -> service.findTravelogueById(1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("존재하지 않는 ID로 여행기를 삭제하면 예외가 발생한다.") + @Test + void deleteTravelogueByNotExistsIdThrowException() { + MemberAuth memberAuth = new MemberAuth(testHelper.initKakaoMemberTestData().getId()); + + assertThatThrownBy(() -> service.deleteTravelogueById(1L, memberAuth)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("작성자가 아닌 사용자가 여행기를 삭제하면 예외가 발생한다.") + @Test + void deleteByIdWithNotAuthor() { + testHelper.initTravelogueTestData(); + MemberAuth notAuthorAuth = new MemberAuth(testHelper.initKakaoMemberTestData().getId()); + + assertThatThrownBy(() -> service.deleteTravelogueById(1L, notAuthorAuth)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다."); + } + + @DisplayName("여행기에 좋아요를 취소 할 수 있다.") + @Test + void unlikeTravelogue() { + Member liker = testHelper.initKakaoMemberTestData(); + Travelogue travelogue = testHelper.initTravelogueTestDataWithLike(liker); + + assertThat(service.unlikeTravelogue(travelogue.getId(), new MemberAuth(liker.getId()))) + .isEqualTo(new TravelogueLikeResponse(false, 0L)); + } + + @DisplayName("존재하지 않는 여행기에 좋아요를 취소 하면 예외가 발생한다.") + @Test + void unlikeTravelogueWithNotExist() { + Member liker = testHelper.initKakaoMemberTestData(); + + assertThatThrownBy(() -> service.unlikeTravelogue(1L, new MemberAuth(liker.getId()))) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java new file mode 100644 index 00000000..6e6458ed --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java @@ -0,0 +1,97 @@ +package kr.touroot.travelogue.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import kr.touroot.global.ServiceTest; +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("여행기 좋아요 서비스") +@Import(value = {TravelogueLikeService.class, TravelogueTestHelper.class}) +@ServiceTest +class TravelogueLikeServiceTest { + + private final TravelogueLikeService travelogueLikeService; + private final DatabaseCleaner databaseCleaner; + private final TravelogueTestHelper testHelper; + + @Autowired + public TravelogueLikeServiceTest( + TravelogueLikeService travelogueLikeService, + DatabaseCleaner databaseCleaner, + TravelogueTestHelper testHelper + ) { + this.travelogueLikeService = travelogueLikeService; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("여행기를 기반으로 좋아요 정보를 조회할 수 있다.") + @Test + void findLikeByTravelogue() { + // given + Member liker = testHelper.initKakaoMemberTestData(); + Travelogue travelogue = testHelper.initTravelogueTestDataWithLike(liker); + + // when + TravelogueLikeResponse response = travelogueLikeService.findLikeByTravelogue(travelogue); + + // then + assertThat(response).isEqualTo(new TravelogueLikeResponse(false, 1L)); + } + + @DisplayName("여행기와 좋아요 한 사람을 기반으로 좋아요 정보를 조회할 수 있다.") + @Test + void findLikeByTravelogueAndLiker() { + // given + Member liker = testHelper.initKakaoMemberTestData(); + Travelogue travelogue = testHelper.initTravelogueTestDataWithLike(liker); + + // when + TravelogueLikeResponse response = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, liker); + + // then + assertThat(response).isEqualTo(new TravelogueLikeResponse(true, 1L)); + } + + @DisplayName("여행기에 좋아요를 할 수 있다.") + @Test + void likeTravelogue() { + // given + Travelogue travelogue = testHelper.initTravelogueTestData(); + Member liker = testHelper.initKakaoMemberTestData(); + + // when + TravelogueLikeResponse response = travelogueLikeService.likeTravelogue(travelogue, liker); + + // then + assertThat(response).isEqualTo(new TravelogueLikeResponse(true, 1L)); + } + + @DisplayName("여행기에 좋아요를 취소 할 수 있다.") + @Test + void unlikeTravelogue() { + // given + Member liker = testHelper.initKakaoMemberTestData(); + Travelogue travelogue = testHelper.initTravelogueTestDataWithLike(liker); + + // when + TravelogueLikeResponse response = travelogueLikeService.unlikeTravelogue(travelogue, liker); + + // then + assertThat(response).isEqualTo(new TravelogueLikeResponse(false, 0L)); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TraveloguePhotoServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TraveloguePhotoServiceTest.java new file mode 100644 index 00000000..7a341389 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/service/TraveloguePhotoServiceTest.java @@ -0,0 +1,113 @@ +package kr.touroot.travelogue.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +import java.util.List; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.image.infrastructure.AwsS3Provider; +import kr.touroot.member.domain.Member; +import kr.touroot.place.domain.Place; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePhoto; +import kr.touroot.travelogue.domain.TraveloguePlace; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.fixture.TravelogueRequestFixture; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.travelogue.repository.TraveloguePhotoRepository; +import kr.touroot.utils.DatabaseCleaner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +@DisplayName("여행기 사진 서비스") +@Import(value = {TraveloguePhotoService.class, TravelogueTestHelper.class, AwsS3Provider.class, + TestQueryDslConfig.class}) +@ServiceTest +class TraveloguePhotoServiceTest { + + private final TraveloguePhotoService photoService; + private final TraveloguePhotoRepository photoRepository; + private final TravelogueTestHelper testHelper; + private final DatabaseCleaner databaseCleaner; + @MockBean + private final AwsS3Provider s3Provider; + + @Autowired + public TraveloguePhotoServiceTest( + TraveloguePhotoService photoService, + TraveloguePhotoRepository photoRepository, + TravelogueTestHelper testHelper, + DatabaseCleaner databaseCleaner, + AwsS3Provider s3Provider + ) { + this.photoService = photoService; + this.photoRepository = photoRepository; + this.testHelper = testHelper; + this.databaseCleaner = databaseCleaner; + this.s3Provider = s3Provider; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("여행기 사진을 생성한다.") + @Test + void createPhotos() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn("imageUrl.png"); + + List requests = TravelogueRequestFixture.getTraveloguePhotoRequests(); + Member author = testHelper.persistMember(); + Travelogue travelogue = testHelper.persistTravelogue(author); + TravelogueDay day = testHelper.persistTravelogueDay(travelogue); + Place position = testHelper.persistPlace(); + TraveloguePlace place = testHelper.persistTraveloguePlace(position, day); + + List photos = photoService.createPhotos(requests, place); + + assertThat(photos).hasSize(requests.size()); + } + + @DisplayName("여행기 사진 URL을 여행기 장소를 기준으로 조회한다.") + @Test + void findPhotoUrlsByPlace() { + Member author = testHelper.persistMember(); + Travelogue travelogue = testHelper.persistTravelogue(author); + TravelogueDay day = testHelper.persistTravelogueDay(travelogue); + Place position = testHelper.persistPlace(); + TraveloguePlace place = testHelper.persistTraveloguePlace(position, day); + TraveloguePhoto photo = testHelper.persistTraveloguePhoto(place); + + List photoUrls = photoService.findPhotoUrlsByPlace(place); + + assertThat(photoUrls).contains(photo.getKey()); + } + + @DisplayName("주어진 여행기의 여행기 사진을 삭제할 수 있다.") + @Test + void deleteTraveloguePhotoById() { + Travelogue travelogue = testHelper.initTravelogueTestData(); + long travelogueId = travelogue.getId(); + photoService.deleteAllByTravelogue(travelogue); + + assertThat(photoRepository.findAll() + .stream() + .noneMatch(photo -> extractTravelogue(photo).getId() == travelogueId)) + .isTrue(); + } + + private Travelogue extractTravelogue(TraveloguePhoto photo) { + return photo.getTraveloguePlace() + .getTravelogueDay() + .getTravelogue(); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TraveloguePlaceServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TraveloguePlaceServiceTest.java new file mode 100644 index 00000000..0e18cb30 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/service/TraveloguePlaceServiceTest.java @@ -0,0 +1,123 @@ +package kr.touroot.travelogue.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.util.List; +import java.util.Map; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.Member; +import kr.touroot.place.domain.Place; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueDay; +import kr.touroot.travelogue.domain.TraveloguePlace; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.fixture.TravelogueRequestFixture; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.travelogue.repository.TraveloguePlaceRepository; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("여행기 장소 서비스") +@Import(value = {TraveloguePlaceService.class, TravelogueTestHelper.class, TestQueryDslConfig.class}) +@ServiceTest +class TraveloguePlaceServiceTest { + + private final TraveloguePlaceService placeService; + private final TraveloguePlaceRepository placeRepository; + private final DatabaseCleaner databaseCleaner; + private final TravelogueTestHelper testHelper; + + @Autowired + public TraveloguePlaceServiceTest( + TraveloguePlaceService placeService, + TraveloguePlaceRepository placeRepository, + DatabaseCleaner databaseCleaner, + TravelogueTestHelper testHelper + ) { + this.placeService = placeService; + this.placeRepository = placeRepository; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("여행기 장소를 생성한다.") + @Test + void createPlaces() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List requests = TravelogueRequestFixture.getTraveloguePlaceRequests(photos); + Member author = testHelper.persistMember(); + Travelogue travelogue = testHelper.persistTravelogue(author); + TravelogueDay day = testHelper.persistTravelogueDay(travelogue); + + Map> placesMap = placeService.createPlaces(requests, day); + List places = placesMap.keySet().stream().toList(); + + assertAll( + () -> assertThat(placesMap.keySet()).hasSize(requests.size()), + () -> assertThat(placesMap).containsEntry(places.get(0), requests.get(0).photoUrls()) + ); + } + + @DisplayName("여행기 장소를 여행기 일자를 기준으로 조회한다.") + @Test + void findTraveloguePlacesByDay() { + Member author = testHelper.persistMember(); + Travelogue travelogue = testHelper.persistTravelogue(author); + TravelogueDay day = testHelper.persistTravelogueDay(travelogue); + Place position = testHelper.persistPlace(); + TraveloguePlace place = testHelper.persistTraveloguePlace(position, day); + + List places = placeService.findTraveloguePlacesByDay(day); + + assertThat(places).contains(place); + } + + @DisplayName("여행기 장소를 ID를 기준으로 조회한다.") + @Test + void findTraveloguePlaceById() { + testHelper.initTravelogueTestData(); + + assertDoesNotThrow(() -> placeService.findTraveloguePlaceById(1L)); + } + + @DisplayName("존재하지 않는 여행기 장소 ID로 조회하면 예외가 발생한다.") + @Test + void findDayByInvalidIdThrowException() { + assertThatThrownBy(() -> placeService.findTraveloguePlaceById(1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기 장소입니다."); + } + + @DisplayName("주어진 여행기의 여행기 장소를 삭제할 수 있다.") + @Test + void deleteTraveloguePlaceById() { + Travelogue travelogue = testHelper.initTravelogueTestData(); + long travelogueId = travelogue.getId(); + placeService.deleteAllByTravelogue(travelogue); + + assertThat(placeRepository.findAll() + .stream() + .noneMatch(place -> extractTravelogue(place).getId() == travelogueId)) + .isTrue(); + } + + private Travelogue extractTravelogue(TraveloguePlace place) { + return place.getTravelogueDay() + .getTravelogue(); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueServiceTest.java new file mode 100644 index 00000000..e9ac1b08 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueServiceTest.java @@ -0,0 +1,209 @@ +package kr.touroot.travelogue.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; + +import java.util.List; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.image.infrastructure.AwsS3Provider; +import kr.touroot.member.domain.Member; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.dto.request.TravelogueDayRequest; +import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest; +import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.fixture.TravelogueRequestFixture; +import kr.touroot.travelogue.fixture.TravelogueResponseFixture; +import kr.touroot.travelogue.helper.TravelogueTestHelper; +import kr.touroot.utils.DatabaseCleaner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Pageable; + +@DisplayName("여행기 서비스") +@Import(value = {TravelogueService.class, TravelogueTestHelper.class, AwsS3Provider.class, TestQueryDslConfig.class}) +@ServiceTest +class TravelogueServiceTest { + + public static final int BASIC_PAGE_SIZE = 5; + + private final TravelogueService travelogueService; + private final DatabaseCleaner databaseCleaner; + private final TravelogueTestHelper testHelper; + @MockBean + private final AwsS3Provider s3Provider; + + @Autowired + public TravelogueServiceTest( + TravelogueService travelogueService, + DatabaseCleaner databaseCleaner, + TravelogueTestHelper testHelper, + AwsS3Provider s3Provider + ) { + this.travelogueService = travelogueService; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + this.s3Provider = s3Provider; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + } + + @DisplayName("여행기를 생성할 수 있다.") + @Test + void createTravelogue() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + Member author = testHelper.initKakaoMemberTestData(); + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + Travelogue createdTravelogue = travelogueService.createTravelogue(author, request); + + assertAll( + () -> assertThat(createdTravelogue.getId()).isEqualTo(1L), + () -> assertThat(createdTravelogue.getTitle()).isEqualTo("제주에 하영 옵서") + ); + } + + private static List getTravelogueDayRequests() { + List photos = TravelogueRequestFixture.getTraveloguePhotoRequests(); + List places = TravelogueRequestFixture.getTraveloguePlaceRequests(photos); + return TravelogueRequestFixture.getTravelogueDayRequests(places); + } + + @DisplayName("여행기는 ID를 기준으로 조회할 수 있다.") + @Test + void getTravelogueById() { + testHelper.initTravelogueTestData(); + + assertDoesNotThrow(() -> travelogueService.getTravelogueById(1L)); + } + + @DisplayName("존재하지 않는 ID로 여행기를 조회하면 예외가 발생한다.") + @Test + void getTravelogueByNotExistsIdThrowException() { + assertThatThrownBy(() -> travelogueService.getTravelogueById(1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("여행기를 전체 조회할 수 있다.") + @Test + void findAll() { + testHelper.initTravelogueTestData(); + + assertThat(travelogueService.findAll(Pageable.ofSize(BASIC_PAGE_SIZE))) + .hasSize(1); + } + + @DisplayName("여행기를 전체 조회할 수 있다.") + @Test + void findByKeyword() { + testHelper.initTravelogueTestData(); + + assertThat(travelogueService.findByKeyword("제주", Pageable.ofSize(BASIC_PAGE_SIZE))) + .hasSize(1); + } + + @DisplayName("존재하지 않는 키워드로 여행기를 조회하면 빈 페이지가 반환된다.") + @Test + void findByKeywordWithNotExistKeyword() { + testHelper.initTravelogueTestData(); + + assertThat(travelogueService.findByKeyword("서울", Pageable.ofSize(BASIC_PAGE_SIZE))) + .isEmpty(); + } + + @DisplayName("여행기를 수정할 수 있다.") + @Test + void updateTravelogue() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(author); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getUpdateTravelogueRequest(days); + Travelogue updatedTravelogue = travelogueService.update(1L, author, request); + + assertAll( + () -> assertThat(updatedTravelogue.getId()).isEqualTo(1L), + () -> assertThat(updatedTravelogue.getTitle()).isEqualTo("삼춘! 제주에 하영 옵서!") + ); + } + + @DisplayName("작성자가 아닌 사람이 여행기를 수정하면 예외가 발생한다.") + @Test + void updateTravelogueWithNotAuthor() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + assertThatThrownBy(() -> travelogueService.update(1L, author, request)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다."); + } + + @DisplayName("존재하지 않는 여행기를 수정하면 예외가 발생한다.") + @Test + void updateTravelogueWithNotExist() { + Mockito.when(s3Provider.copyImageToPermanentStorage(any(String.class))) + .thenReturn(TravelogueResponseFixture.getTravelogueResponse().thumbnail()); + + Member author = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestData(author); + + List days = getTravelogueDayRequests(); + TravelogueRequest request = TravelogueRequestFixture.getTravelogueRequest(days); + + assertThatThrownBy(() -> travelogueService.update(0L, author, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("여행기를 삭제할 수 있다.") + @Test + void deleteTravelogueById() { + Member author = testHelper.initKakaoMemberTestData(); + Travelogue travelogue = testHelper.initTravelogueTestData(author); + long travelogueId = travelogue.getId(); + + travelogueService.delete(travelogue, author); + + assertThatThrownBy(() -> travelogueService.getTravelogueById(travelogueId)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행기입니다."); + } + + @DisplayName("작성자가 아닌 사람이 여행기를 삭제하면 예외가 발생한다.") + @Test + void deleteTravelogueByNotAuthorThrowException() { + Travelogue travelogue = testHelper.initTravelogueTestData(); + Member notAuthor = testHelper.initKakaoMemberTestData(); + + assertThatThrownBy(() -> travelogueService.delete(travelogue, notAuthor)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("본인이 작성한 여행기만 수정하거나 삭제할 수 있습니다."); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/controller/PlaceTodoControllerTest.java b/backend/src/test/java/kr/touroot/travelplan/controller/PlaceTodoControllerTest.java new file mode 100644 index 00000000..9811645b --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/controller/PlaceTodoControllerTest.java @@ -0,0 +1,106 @@ +package kr.touroot.travelplan.controller; + + +import static org.hamcrest.Matchers.is; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.dto.request.TodoStatusUpdateRequest; +import kr.touroot.travelplan.helper.TravelPlanTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; + +@DisplayName("여행 계획 장소에 대한 TODO 컨트롤러") +@AcceptanceTest +class PlaceTodoControllerTest { + + private final ObjectMapper objectMapper; + private final DatabaseCleaner databaseCleaner; + private final JwtTokenProvider jwtTokenProvider; + private final TravelPlanTestHelper testHelper; + @LocalServerPort + private int port; + private String accessToken; + private Member member; + + @Autowired + public PlaceTodoControllerTest(ObjectMapper objectMapper, DatabaseCleaner databaseCleaner, + JwtTokenProvider jwtTokenProvider, TravelPlanTestHelper testHelper) { + this.objectMapper = objectMapper; + this.databaseCleaner = databaseCleaner; + this.jwtTokenProvider = jwtTokenProvider; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + RestAssured.port = port; + databaseCleaner.executeTruncate(); + + member = testHelper.initMemberTestData(); + accessToken = jwtTokenProvider.createToken(member.getId()).accessToken(); + } + + @DisplayName("TODO의 체크 상태를 수정할 수 있다") + @Test + void updateTodoStatus() { + testHelper.initTravelPlanTestData(member); + TodoStatusUpdateRequest todoStatusUpdateRequest = new TodoStatusUpdateRequest(true); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(todoStatusUpdateRequest) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .patch("/api/v1/todos/" + 1L) + .then().log().all() + .statusCode(200) + .body("checked", is(true)); + } + + @DisplayName("존재하지 않는 TODO의 체크 상태를 업데이트 하려는 경우 예외가 발생한다") + @Test + void updateNonExistTodoStatus() { + TodoStatusUpdateRequest todoStatusUpdateRequest = new TodoStatusUpdateRequest(true); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(todoStatusUpdateRequest) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .patch("/api/v1/todos/" + 1L) + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 TODO 입니다")); + } + + @DisplayName("TODO 작성자가 아닌 멤버가 TODO의 체크 상태를 업데이트 하려는 경우 예외가 발생한다.") + @Test + void updateTodoStatusFromNonAuthor() { + testHelper.initTravelPlanTestData(member); + + TodoStatusUpdateRequest todoStatusUpdateRequest = new TodoStatusUpdateRequest(true); + Member notAuthor = testHelper.initMemberTestData(); + String notAuthorAccessToken = jwtTokenProvider.createToken(notAuthor.getId()) + .accessToken(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(todoStatusUpdateRequest) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + notAuthorAccessToken) + .when().log().all() + .patch("/api/v1/todos/" + 1L) + .then().log().all() + .statusCode(403) + .body("message", is("TODO 체크는 작성자만 가능합니다")); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/controller/TravelPlanControllerTest.java b/backend/src/test/java/kr/touroot/travelplan/controller/TravelPlanControllerTest.java new file mode 100644 index 00000000..ff2818ce --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/controller/TravelPlanControllerTest.java @@ -0,0 +1,379 @@ +package kr.touroot.travelplan.controller; + +import static org.hamcrest.Matchers.is; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import kr.touroot.authentication.infrastructure.JwtTokenProvider; +import kr.touroot.global.AcceptanceTest; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.dto.request.PlanDayRequest; +import kr.touroot.travelplan.dto.request.PlanPlaceRequest; +import kr.touroot.travelplan.dto.request.PlanPositionRequest; +import kr.touroot.travelplan.dto.request.PlanRequest; +import kr.touroot.travelplan.helper.TravelPlanTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; + +@DisplayName("여행 계획 컨트롤러") +@AcceptanceTest +class TravelPlanControllerTest { + + private final ObjectMapper objectMapper; + private final DatabaseCleaner databaseCleaner; + private final JwtTokenProvider jwtTokenProvider; + private final TravelPlanTestHelper testHelper; + @LocalServerPort + private int port; + private String accessToken; + private Member member; + + @Autowired + public TravelPlanControllerTest( + DatabaseCleaner databaseCleaner, + TravelPlanTestHelper testHelper, + JwtTokenProvider jwtTokenProvider, + ObjectMapper objectMapper + ) { + this.objectMapper = objectMapper; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + this.jwtTokenProvider = jwtTokenProvider; + } + + @BeforeEach + void setUp() { + RestAssured.port = port; + databaseCleaner.executeTruncate(); + + member = testHelper.initMemberTestData(); + accessToken = jwtTokenProvider.createToken(member.getId()).accessToken(); + } + + @DisplayName("여행 계획 컨트롤러는 생성 요청이 들어올 때 200을 응답한다.") + @Test + void createTravelPlan() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().log().all() + .post("/api/v1/travel-plans") + .then().log().all() + .statusCode(201) + .body("id", is(1)); + } + + @DisplayName("여행 계획 컨트롤러는 지난 날짜로 생성 요청이 들어올 때 400을 응답한다.") + @Test + void createTravelPlanWithInvalidStartDate() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MIN) + .days(List.of(planDayRequest)) + .build(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().log().all() + .post("/api/v1/travel-plans") + .then().log().all() + .statusCode(400) + .body("message", is("지난 날짜에 대한 계획은 작성할 수 없습니다.")); + } + + @DisplayName("여행 계획 컨트롤러는 상세 조회 요청이 들어오면 200을 응답한다.") + @Test + void readTravelPlan() { + // given + testHelper.initTravelPlanTestData(member); + long id = 1L; + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(200) + .body("id", is(1)); + } + + @DisplayName("여행 계획 컨트롤러는 존재하지 않는 상세 조회 요청이 들어오면 400을 응답한다.") + @Test + void readTravelPlanWithNonExist() { + // given + long id = 1L; + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 여행 계획입니다.")); + } + + @DisplayName("여행 계획 컨트롤러는 작성자가 아닌 사용자가 조회 시 403을 응답한다.") + @Test + void readTravelPlanWithNotAuthor() { + // given + long id = testHelper.initTravelPlanTestData(member).getId(); + Member notAuthor = testHelper.initMemberTestData(); + String notAuthorAccessToken = jwtTokenProvider.createToken(notAuthor.getId()) + .accessToken(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + notAuthorAccessToken) + .when().log().all() + .get("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(403) + .body("message", is("여행 계획 조회는 작성자만 가능합니다.")); + } + + @DisplayName("여행 계획 공유 키를 통해 여행 계획을 조회할 수 있다") + @Test + void readTravelPlanByShareKey() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(member); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/travel-plans/shared/" + travelPlan.getShareKey()) + .then().log().all() + .statusCode(200) + .body("shareKey", is(travelPlan.getShareKey().toString())); + } + + @DisplayName("공유된 여행 계획은 작성자가 아닌 회원도 조회할 수 있다") + @Test + void readTravelPlanByShareKeyFromNoAuthor() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(member); + Member notAuthor = testHelper.initMemberTestData(); + String notAuthorAccessToken = jwtTokenProvider.createToken(notAuthor.getId()) + .accessToken(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + notAuthorAccessToken) + .when().log().all() + .get("/api/v1/travel-plans/shared/" + travelPlan.getShareKey()) + .then().log().all() + .statusCode(200) + .body("shareKey", is(travelPlan.getShareKey().toString())); + } + + @DisplayName("공유된 여행 계획은 로그인되지 않은 유저도 조회할 수 있다") + @Test + void readTravelPlanByNotLoginUser() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(member); + Member notAuthor = testHelper.initMemberTestData(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().log().all() + .get("/api/v1/travel-plans/shared/" + travelPlan.getShareKey()) + .then().log().all() + .statusCode(200) + .body("shareKey", is(travelPlan.getShareKey().toString())); + } + + @DisplayName("공유된 여행 계획을 조회할 때 존재하지 않는 공유 키로 조회할 경우 400을 응답한다") + @Test + void readTravelPlanByInvalidShareKey() { + // given + testHelper.initTravelPlanTestData(member); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/travel-plans/shared/" + UUID.randomUUID()) + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 여행 계획입니다.")); + } + + @DisplayName("여행기를 수정한다.") + @Test + void updateTravelPlan() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(member); + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().put("/api/v1/travel-plans/" + travelPlan.getId()) + .then().log().all() + .statusCode(200) + .body("id", is(1)); + } + + @DisplayName("존재하지 않는 여행 계획 수정시 400를 응답한다.") + @Test + void updateTravelPlanWithNonExist() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .body(request) + .when().put("/api/v1/travel-plans/" + 1) + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 여행 계획입니다.")); + } + + @DisplayName("작성자가 아닌 사용자가 여행 계획 수정시 403을 응답한다.") + @Test + void updateTravelPlanWithNotAuthor() { + // given + long id = testHelper.initTravelPlanTestData(member).getId(); + Member notAuthor = testHelper.initMemberTestData(); + String notAuthorAccessToken = jwtTokenProvider.createToken(notAuthor.getId()).accessToken(); + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + notAuthorAccessToken) + .body(request) + .when().put("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(403) + .body("message", is("여행 계획 수정은 작성자만 가능합니다.")); + } + + @DisplayName("여행계획을 삭제한다.") + @Test + void deleteTravelPlan() { + long id = testHelper.initTravelPlanTestData(member).getId(); + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(204); + } + + @DisplayName("존재하지 않는 여행 계획 삭제시 400를 응답한다.") + @Test + void deleteTravelPlanWithNonExist() { + long id = 1L; + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().delete("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(400) + .body("message", is("존재하지 않는 여행 계획입니다.")); + } + + @DisplayName("작성자가 아닌 사용자가 여행 계획 삭제시 403을 응답한다.") + @Test + void deleteTravelPlanWithNotAuthor() { + long id = testHelper.initTravelPlanTestData(member).getId(); + Member notAuthor = testHelper.initMemberTestData(); + String notAuthorAccessToken = jwtTokenProvider.createToken(notAuthor.getId()).accessToken(); + + RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + notAuthorAccessToken) + .when().delete("/api/v1/travel-plans/" + id) + .then().log().all() + .statusCode(403) + .body("message", is("여행 계획 삭제는 작성자만 가능합니다.")); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlaceTodoTest.java b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlaceTodoTest.java new file mode 100644 index 00000000..4d3362ce --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlaceTodoTest.java @@ -0,0 +1,98 @@ +package kr.touroot.travelplan.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.travelplan.fixture.TravelPlanPlaceFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행 계획 장소의 TODO") +class TravelPlaceTodoTest { + + private static final TravelPlanPlace VALID_PLACE = TravelPlanPlaceFixture.TRAVEL_PLAN_PLACE.get(); + private static final String VALID_CONTENT = "마라탕 먹기"; + private static final Integer VALID_ORDER = 0; + private static final Boolean VALID_CHECK = Boolean.TRUE; + private static final String EMPTY_ERROR_MESSAGE = "여행 계획 장소에 대한 TODO에서 장소와 내용, 순서 그리고 달성 여부는 비어 있을 수 없습니다"; + + @DisplayName("올바른 형식으로 TODO를 생성 시 예외가 발생하지 않는다") + @Test + void createValidTodo() { + assertThatCode(() -> new TravelPlaceTodo(VALID_PLACE, VALID_CONTENT, VALID_ORDER, VALID_CHECK)) + .doesNotThrowAnyException(); + } + + @DisplayName("TODO가 속하는 여행 계획 장소가 비어 있을 경우 TODO 생성 시 예외가 발생한다") + @Test + void createTodoWithNullPlace() { + assertThatThrownBy(() -> new TravelPlaceTodo(null, VALID_CONTENT, VALID_ORDER, VALID_CHECK)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_ERROR_MESSAGE); + } + + @DisplayName("TODO의 내용이 비어 있을 경우 TODO 생성 시 예외가 발생한다") + @Test + void createTodoWithNullContent() { + assertThatThrownBy(() -> new TravelPlaceTodo(VALID_PLACE, null, VALID_ORDER, VALID_CHECK)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_ERROR_MESSAGE); + } + + @DisplayName("TODO의 순서가 비어 있을 경우 TODO 생성 시 예외가 발생한다") + @Test + void createTodoWithNullOrder() { + assertThatThrownBy(() -> new TravelPlaceTodo(VALID_PLACE, VALID_CONTENT, null, VALID_CHECK)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_ERROR_MESSAGE); + } + + @DisplayName("TODO의 체크 여부가 비어 있을 경우 TODO 생성 시 예외가 발생한다") + @Test + void createTodoWithNullCheck() { + assertThatThrownBy(() -> new TravelPlaceTodo(VALID_PLACE, VALID_CONTENT, VALID_ORDER, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_ERROR_MESSAGE); + } + + @DisplayName("TODO의 내용이 공백인 경우 TODO 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {" ", " ", "\n"}) + void createTodoWithBlankContent(String blank) { + assertThatThrownBy(() -> new TravelPlaceTodo(VALID_PLACE, blank, VALID_ORDER, VALID_CHECK)) + .isInstanceOf(BadRequestException.class) + .hasMessage("TODO 내용은 빈 문자열로만 이루어질 수 없습니다"); + } + + @DisplayName("TODO의 내용 길이가 범위를 벗어나면 TODO 생성 시 예외가 발생한다") + @Test + void createTodoWithInvalidLengthContent() { + String length21 = "서울 명동에서 패션 쇼핑과 길거리 음식"; + System.out.println("length21 = " + length21.length()); + assertThatThrownBy(() -> new TravelPlaceTodo(VALID_PLACE, length21, VALID_ORDER, VALID_CHECK)) + .isInstanceOf(BadRequestException.class) + .hasMessage("TODO 내용의 길이는 1자 이상, 20자 이하여야 합니다"); + } + + @DisplayName("TODO의 순서가 음수인 경우 TODO 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(ints = {-1, -2, -3, -4, -5}) + void createTodoWithNegativeOrder(int negative) { + assertThatThrownBy(() -> new TravelPlaceTodo(VALID_PLACE, VALID_CONTENT, negative, VALID_CHECK)) + .isInstanceOf(BadRequestException.class) + .hasMessage("TODO 순서는 음수일 수 없습니다"); + } + + @DisplayName("TODO의 상태를 업데이트 할 수 있다") + @Test + void updateCheckStatus() { + TravelPlaceTodo todo = new TravelPlaceTodo(VALID_PLACE, VALID_CONTENT, VALID_ORDER, VALID_CHECK); + todo.updateCheckedStatus(false); + + assertThat(todo.getIsChecked()).isFalse(); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanDayTest.java b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanDayTest.java new file mode 100644 index 00000000..4f0fc954 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanDayTest.java @@ -0,0 +1,50 @@ +package kr.touroot.travelplan.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.travelplan.fixture.TravelPlanFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행 계획 날짜") +class TravelPlanDayTest { + + private static final Integer VALID_ORDER = 0; + private static final TravelPlan VALID_PLAN = TravelPlanFixture.TRAVEL_PLAN.get(); + + @DisplayName("올바른 여행 계획 날짜 생성 시 예외가 발생하지 않는다") + @Test + void createTravelPlanDayWithValidData() { + assertThatCode(() -> new TravelPlanDay(VALID_ORDER, VALID_PLAN)) + .doesNotThrowAnyException(); + } + + @DisplayName("날짜의 순서가 비어 있을 경우 여행 계획 날짜 생성 시 예외가 발생한다") + @Test + void createTravelPlanDayWithNullOrder() { + assertThatThrownBy(() -> new TravelPlanDay(null, VALID_PLAN)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획 날짜에서 순서와 속하고 있는 여행 계획은 비어 있을 수 없습니다"); + } + + @DisplayName("날짜가 속한 여행 계획이 비어 있을 경우 여행 계획 날짜 생성 시 예외가 발생한다") + @Test + void createTravelPlanDayWithNullPlan() { + assertThatThrownBy(() -> new TravelPlanDay(VALID_ORDER, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획 날짜에서 순서와 속하고 있는 여행 계획은 비어 있을 수 없습니다"); + } + + @DisplayName("여행 계획 날짜의 순서가 음수인 경우 여행 계획 날짜 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(ints = {-1, -2, -3, -4, -5}) + void createTravelPlanDayWithNegativeOrder(int negative) { + assertThatThrownBy(() -> new TravelPlanDay(negative, VALID_PLAN)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획 날짜 순서는 음수일 수 없습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanPlaceTest.java b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanPlaceTest.java new file mode 100644 index 00000000..776606c6 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanPlaceTest.java @@ -0,0 +1,61 @@ +package kr.touroot.travelplan.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.place.domain.Place; +import kr.touroot.place.fixture.PlaceFixture; +import kr.touroot.travelplan.fixture.TravelPlanDayFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행 계획 장소") +class TravelPlanPlaceTest { + + private static final Integer VALID_ORDER = 0; + private static final TravelPlanDay VALID_DAY = TravelPlanDayFixture.TRAVEL_PLAN_DAY.get(); + private static final Place VALID_PLACE = PlaceFixture.PLACE.get(); + + @DisplayName("올바른 여행 계획 장소 생성 시 예외가 발생하지 않는다") + @Test + void createTravelPlanPlaceWithValidData() { + assertThatCode(() -> new TravelPlanPlace(VALID_ORDER, VALID_DAY, VALID_PLACE)) + .doesNotThrowAnyException(); + } + + @DisplayName("방문 순서가 비어 있을 경우 여행 계획 장소 생성 시 예외가 발생한다") + @Test + void createTravelPlanPlaceWithNullOrder() { + assertThatThrownBy(() -> new TravelPlanPlace(null, VALID_DAY, VALID_PLACE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획 장소에서 순서와 날짜, 그리고 장소 상세는 비어 있을 수 없습니다"); + } + + @DisplayName("장소의 방문 날짜가 비어 있을 경우 여행 계획 장소 생성 시 예외가 발생한다") + @Test + void createTravelPlanPlaceWithNullDay() { + assertThatThrownBy(() -> new TravelPlanPlace(VALID_ORDER, null, VALID_PLACE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획 장소에서 순서와 날짜, 그리고 장소 상세는 비어 있을 수 없습니다"); + } + + @DisplayName("장소 상세 정보가 비어 있을 경우 여행 계획 장소 생성 시 예외가 발생한다") + @Test + void createTravelPlanPlaceWithPlaceNull() { + assertThatThrownBy(() -> new TravelPlanPlace(VALID_ORDER, VALID_DAY, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획 장소에서 순서와 날짜, 그리고 장소 상세는 비어 있을 수 없습니다"); + } + + @DisplayName("여행 계획 장소의 방문 순서가 음수인 경우 여행 계획 장소 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(ints = {-1, -2, -3, -4, -5}) + void createTravelPlanPlaceWithNegativeOrder(int negative) { + assertThatThrownBy(() -> new TravelPlanPlace(negative, VALID_DAY, VALID_PLACE)) + .isInstanceOf(BadRequestException.class) + .hasMessage("장소의 방문 순서는 음수일 수 없습니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanTest.java b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanTest.java new file mode 100644 index 00000000..6d974348 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/domain/TravelPlanTest.java @@ -0,0 +1,110 @@ +package kr.touroot.travelplan.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.UUID; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.member.domain.LoginType; +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("여행 계획") +class TravelPlanTest { + + private static final Member VALID_AUTHOR = MemberFixture.KAKAO_MEMBER.build(); + private static final String VALID_TITLE = "제주도 여행 계획"; + private static final UUID VALID_UUID = UUID.randomUUID(); + private static final LocalDate VALID_START_DATE = LocalDate.now().plusDays(2); + public static final String EMPTY_FIELD_EXIST_MESSAGE = "여행 계획에서 제목과 시작 날짜, 공유 키, 그리고 작성자는 비어 있을 수 없습니다"; + + @DisplayName("올바른 여행 계획 생성 시에는 예외가 발생하지 않는다") + @Test + void createTravelPlanWithValidData() { + assertThatCode(() -> new TravelPlan(VALID_TITLE, VALID_START_DATE, VALID_UUID, VALID_AUTHOR)) + .doesNotThrowAnyException(); + } + + @DisplayName("여행 계획의 제목이 비어 있는 경우 여행 계획 생성 시 예외가 발생한다") + @Test + void createTravelPlanWithNullTitle() { + assertThatThrownBy(() -> new TravelPlan(null, VALID_START_DATE, VALID_UUID, VALID_AUTHOR)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_FIELD_EXIST_MESSAGE); + } + + @DisplayName("여행 계획의 시작 날짜가 비어 있는 경우 여행 계획 생성 시 예외가 발생한다") + @Test + void createTravelPlanWithNullStartDate() { + assertThatThrownBy(() -> new TravelPlan(VALID_TITLE, null, VALID_UUID, VALID_AUTHOR)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_FIELD_EXIST_MESSAGE); + } + + @DisplayName("여행 게획의 공유 키가 비어 있는 경우 여행 계획 생성 시 예외가 발생한다") + @Test + void createTravelPlanWithNullShareKey() { + assertThatThrownBy(() -> new TravelPlan(VALID_TITLE, null, VALID_UUID, VALID_AUTHOR)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EMPTY_FIELD_EXIST_MESSAGE); + } + + @DisplayName("여행 계획 제목이 공백 문자로만 이루어져 있는 경우 생성 시 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {" ", " ", " "}) + void createTravelPlanWithBlankTitle(String blank) { + assertThatThrownBy(() -> new TravelPlan(blank, VALID_START_DATE, VALID_UUID, VALID_AUTHOR)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획에서 제목은 공백 문자로만 이루어질 수 없습니다"); + } + + @DisplayName("여행 계획 제목이 1자에서 20자 사이의 길이가 아니라면 생성 시 예외가 발생한다") + @Test + void createTravelPlanWithInvalidLengthTitle() { + String length21 = "서울 명동: 패션 쇼핑과 길거리 음식,"; + + assertThatThrownBy(() -> new TravelPlan(length21, VALID_START_DATE, VALID_UUID, VALID_AUTHOR)) + .isInstanceOf(BadRequestException.class) + .hasMessage("여행 계획은 1자 이상, 20자 이하여야 합니다"); + } + + @DisplayName("여행 계획의 시작날짜와 특정 날짜를 비교할 수 있다.") + @Test + void validateStartDate() { + // given + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDate today = LocalDate.now(); + LocalDate tommorow = LocalDate.now().plusDays(1); + + Member author = new Member(1L, 1L, null, null, "tester", "http://url.com", LoginType.KAKAO); + TravelPlan todayPlan = new TravelPlan("test", today, VALID_UUID, author); + + // when & then + assertAll( + () -> assertThat(todayPlan.isStartDateBefore(tommorow)).isTrue(), + () -> assertThat(todayPlan.isStartDateBefore(yesterday)).isFalse() + ); + } + + @DisplayName("여행 계획은 작성자가 아닌 사용자가 검증을 시도하면 예외가 발생한다.") + @Test + void validateAuthor() { + // given + Member author = new Member(1L, 1L, null, null, "tester", "http://url.com", LoginType.KAKAO); + TravelPlan travelPlan = new TravelPlan("test", LocalDate.now(), VALID_UUID, author); + Member notAuthor = new Member(2L, 2L, null, null, "tester2", "http://url.com", LoginType.KAKAO); + + // when + boolean actual = travelPlan.isAuthor(notAuthor); + + // then + assertThat(actual).isFalse(); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanDayFixture.java b/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanDayFixture.java new file mode 100644 index 00000000..c5dcefb9 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanDayFixture.java @@ -0,0 +1,20 @@ +package kr.touroot.travelplan.fixture; + +import static kr.touroot.travelplan.fixture.TravelPlanFixture.TRAVEL_PLAN; + +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanDay; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TravelPlanDayFixture { + + TRAVEL_PLAN_DAY(0, TRAVEL_PLAN.get()); + + private final int order; + private final TravelPlan travelPlan; + + public TravelPlanDay get() { + return new TravelPlanDay(order, travelPlan); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanFixture.java b/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanFixture.java new file mode 100644 index 00000000..9a35c57b --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanFixture.java @@ -0,0 +1,23 @@ +package kr.touroot.travelplan.fixture; + + +import java.time.LocalDate; +import java.util.UUID; +import kr.touroot.member.domain.Member; +import kr.touroot.member.fixture.MemberFixture; +import kr.touroot.travelplan.domain.TravelPlan; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TravelPlanFixture { + + TRAVEL_PLAN("제주도 여행 계획", LocalDate.now().plusDays(2), MemberFixture.KAKAO_MEMBER.build()); + + private final String title; + private final LocalDate startDate; + private final Member author; + + public TravelPlan get() { + return new TravelPlan(title, startDate, UUID.randomUUID(), author); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanPlaceFixture.java b/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanPlaceFixture.java new file mode 100644 index 00000000..6a85b331 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/fixture/TravelPlanPlaceFixture.java @@ -0,0 +1,27 @@ +package kr.touroot.travelplan.fixture; + +import kr.touroot.place.domain.Place; +import kr.touroot.place.fixture.PlaceFixture; +import kr.touroot.travelplan.domain.TravelPlanDay; +import kr.touroot.travelplan.domain.TravelPlanPlace; + +public enum TravelPlanPlaceFixture { + + TRAVEL_PLAN_PLACE("함덕 해수욕장 조아요!", 0, TravelPlanDayFixture.TRAVEL_PLAN_DAY.get(), PlaceFixture.PLACE.get()); + + private final String description; + private final Integer order; + private final TravelPlanDay travelPlanDay; + private final Place place; + + TravelPlanPlaceFixture(String description, Integer order, TravelPlanDay travelPlanDay, Place place) { + this.description = description; + this.order = order; + this.travelPlanDay = travelPlanDay; + this.place = place; + } + + public TravelPlanPlace get() { + return new TravelPlanPlace(order, travelPlanDay, place); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/helper/TravelPlanTestHelper.java b/backend/src/test/java/kr/touroot/travelplan/helper/TravelPlanTestHelper.java new file mode 100644 index 00000000..23635823 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/helper/TravelPlanTestHelper.java @@ -0,0 +1,111 @@ +package kr.touroot.travelplan.helper; + +import java.time.LocalDate; +import java.util.UUID; +import kr.touroot.member.domain.LoginType; +import kr.touroot.member.domain.Member; +import kr.touroot.member.repository.MemberRepository; +import kr.touroot.place.domain.Place; +import kr.touroot.place.repository.PlaceRepository; +import kr.touroot.travelplan.domain.TravelPlaceTodo; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.domain.TravelPlanDay; +import kr.touroot.travelplan.domain.TravelPlanPlace; +import kr.touroot.travelplan.repository.PlaceTodoRepository; +import kr.touroot.travelplan.repository.TravelPlanDayRepository; +import kr.touroot.travelplan.repository.TravelPlanPlaceRepository; +import kr.touroot.travelplan.repository.TravelPlanRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TravelPlanTestHelper { + + private final PlaceRepository placeRepository; + private final TravelPlanRepository travelPlanRepository; + private final TravelPlanDayRepository travelPlanDayRepository; + private final TravelPlanPlaceRepository travelPlanPlaceRepository; + private final MemberRepository memberRepository; + private final PlaceTodoRepository placeTodoRepository; + + @Autowired + public TravelPlanTestHelper( + PlaceRepository placeRepository, + TravelPlanRepository travelPlanRepository, + TravelPlanDayRepository travelPlanDayRepository, + TravelPlanPlaceRepository travelPlanPlaceRepository, + MemberRepository memberRepository, + PlaceTodoRepository placeTodoRepository + ) { + this.placeRepository = placeRepository; + this.travelPlanRepository = travelPlanRepository; + this.travelPlanDayRepository = travelPlanDayRepository; + this.travelPlanPlaceRepository = travelPlanPlaceRepository; + this.memberRepository = memberRepository; + this.placeTodoRepository = placeTodoRepository; + } + + public static Member getKakaoMember(Long kakaoId, String nickname, String profileImageUri) { + return new Member(kakaoId, nickname, profileImageUri, LoginType.KAKAO); + } + + public static Place getPlace(String name, String latitude, String longitude, String googlePlaceId) { + return new Place(name, latitude, longitude, googlePlaceId); + } + + public static TravelPlan getTravelPlan(String title, LocalDate startDate, Member author) { + return new TravelPlan(title, startDate, UUID.randomUUID(), author); + } + + public static TravelPlanDay getTravelPlanDay(int order, TravelPlan travelPlan) { + return new TravelPlanDay(order, travelPlan); + } + + public static TravelPlanPlace getTravelPlanPlace(int order, Place place, TravelPlanDay day) { + return new TravelPlanPlace(order, day, place); + } + + public static TravelPlaceTodo getTravelPlaceTodo(TravelPlanPlace travelPlanPlace, String content, Integer order, + Boolean isChecked) { + return new TravelPlaceTodo(travelPlanPlace, content, order, isChecked); + } + + + public TravelPlan initTravelPlanTestData() { + Member author = initMemberTestData(); + TravelPlan travelPlan = getTravelPlan("여행계획", LocalDate.MAX, author); + TravelPlanDay travelPlanDay = getTravelPlanDay(0, travelPlan); + Place place = getPlace("장소", "37.5175896", "127.0867236", ""); + TravelPlanPlace travelPlanPlace = getTravelPlanPlace(0, place, travelPlanDay); + TravelPlaceTodo travelPlaceTodo = getTravelPlaceTodo(travelPlanPlace, "테스트짜기", 0, false); + + travelPlanRepository.save(travelPlan); + travelPlanDayRepository.save(travelPlanDay); + placeRepository.save(place); + travelPlanPlaceRepository.save(travelPlanPlace); + placeTodoRepository.save(travelPlaceTodo); + + return travelPlan; + } + + public TravelPlan initTravelPlanTestData(Member author) { + TravelPlan travelPlan = getTravelPlan("여행계획", LocalDate.MAX, author); + TravelPlanDay travelPlanDay = getTravelPlanDay(0, travelPlan); + Place place = getPlace("장소", "37.5175896", "127.0867236", ""); + TravelPlanPlace travelPlanPlace = getTravelPlanPlace(0, place, travelPlanDay); + TravelPlaceTodo travelPlaceTodo = getTravelPlaceTodo(travelPlanPlace, "테스트짜기", 0, false); + + travelPlanRepository.save(travelPlan); + travelPlanDayRepository.save(travelPlanDay); + placeRepository.save(place); + travelPlanPlaceRepository.save(travelPlanPlace); + placeTodoRepository.save(travelPlaceTodo); + + return travelPlan; + } + + public Member initMemberTestData() { + Member member = getKakaoMember(1L, "tester", "https://dev.touroot.kr/temporary/profile.png"); + return memberRepository.save(member); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/service/PlaceTodoServiceTest.java b/backend/src/test/java/kr/touroot/travelplan/service/PlaceTodoServiceTest.java new file mode 100644 index 00000000..1ec7800d --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/service/PlaceTodoServiceTest.java @@ -0,0 +1,85 @@ +package kr.touroot.travelplan.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.ServiceTest; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.config.TestQueryDslConfig; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.dto.request.TodoStatusUpdateRequest; +import kr.touroot.travelplan.dto.response.PlanPlaceTodoResponse; +import kr.touroot.travelplan.helper.TravelPlanTestHelper; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("TODO 서비스") +@Import({PlaceTodoService.class, TravelPlanTestHelper.class, TestQueryDslConfig.class}) +@ServiceTest +class PlaceTodoServiceTest { + + private final PlaceTodoService placeTodoService; + private final DatabaseCleaner databaseCleaner; + private final TravelPlanTestHelper testHelper; + + private MemberAuth memberAuth; + private Member author; + + @Autowired + public PlaceTodoServiceTest( + PlaceTodoService placeTodoService, + DatabaseCleaner databaseCleaner, + TravelPlanTestHelper testHelper + ) { + this.placeTodoService = placeTodoService; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + author = testHelper.initMemberTestData(); + memberAuth = new MemberAuth(author.getId()); + } + + @DisplayName("저장되어 있는 TODO의 체크 상태를 변경할 수 있다") + @Test + void updateTodoCheckStatus() { + TravelPlan savedPlan = testHelper.initTravelPlanTestData(author); + TodoStatusUpdateRequest updateRequest = new TodoStatusUpdateRequest(true); + + PlanPlaceTodoResponse updateTodoResponse = placeTodoService.updateTodoStatus(1L, memberAuth, updateRequest); + + assertThat(updateTodoResponse.checked()).isTrue(); + } + + @DisplayName("존재하지 않는 TODO의 체크 상태를 업데이트 하려고 할 경우 예외가 발생한다") + @Test + void updateNonExistTodoCheckStatus() { + TodoStatusUpdateRequest updateRequest = new TodoStatusUpdateRequest(true); + assertThatThrownBy(() -> placeTodoService.updateTodoStatus(1L, memberAuth, updateRequest)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 TODO 입니다"); + } + + @DisplayName("작성자가 아닌 멤버가 TODO를 업데이트하려고 하는 경우 예외가 발생한다") + @Test + void updateTodoCheckStatusFromNonAuthor() { + TravelPlan savedPlan = testHelper.initTravelPlanTestData(author); + Member notAuthor = testHelper.initMemberTestData(); + MemberAuth nonAuthorAccessor = new MemberAuth(notAuthor.getId()); + + TodoStatusUpdateRequest updateRequest = new TodoStatusUpdateRequest(true); + assertThatThrownBy(() -> placeTodoService.updateTodoStatus(1L, nonAuthorAccessor, updateRequest)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("TODO 체크는 작성자만 가능합니다"); + } +} diff --git a/backend/src/test/java/kr/touroot/travelplan/service/TravelPlanServiceTest.java b/backend/src/test/java/kr/touroot/travelplan/service/TravelPlanServiceTest.java new file mode 100644 index 00000000..d591b6bd --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelplan/service/TravelPlanServiceTest.java @@ -0,0 +1,321 @@ +package kr.touroot.travelplan.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import kr.touroot.global.ServiceTest; +import kr.touroot.global.auth.dto.MemberAuth; +import kr.touroot.global.exception.BadRequestException; +import kr.touroot.global.exception.ForbiddenException; +import kr.touroot.member.domain.Member; +import kr.touroot.travelplan.domain.TravelPlan; +import kr.touroot.travelplan.dto.request.PlanDayRequest; +import kr.touroot.travelplan.dto.request.PlanPlaceRequest; +import kr.touroot.travelplan.dto.request.PlanPositionRequest; +import kr.touroot.travelplan.dto.request.PlanRequest; +import kr.touroot.travelplan.dto.response.PlanCreateResponse; +import kr.touroot.travelplan.dto.response.PlanResponse; +import kr.touroot.travelplan.helper.TravelPlanTestHelper; +import kr.touroot.travelplan.repository.TravelPlanRepository; +import kr.touroot.utils.DatabaseCleaner; +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.context.annotation.Import; + +@DisplayName("여행 계획 서비스") +@Import(value = {TravelPlanService.class, TravelPlanTestHelper.class}) +@ServiceTest +class TravelPlanServiceTest { + + private final TravelPlanService travelPlanService; + private final TravelPlanRepository travelPlanRepository; + private final DatabaseCleaner databaseCleaner; + private final TravelPlanTestHelper testHelper; + + private MemberAuth memberAuth; + private Member author; + + @Autowired + public TravelPlanServiceTest( + TravelPlanService travelPlanService, + TravelPlanRepository travelPlanRepository, + DatabaseCleaner databaseCleaner, + TravelPlanTestHelper testHelper + ) { + this.travelPlanService = travelPlanService; + this.travelPlanRepository = travelPlanRepository; + this.databaseCleaner = databaseCleaner; + this.testHelper = testHelper; + } + + @BeforeEach + void setUp() { + databaseCleaner.executeTruncate(); + + author = testHelper.initMemberTestData(); + memberAuth = new MemberAuth(author.getId()); + } + + @DisplayName("여행 계획 서비스는 여행 계획 생성 시 생성된 id를 응답한다.") + @Test + void createTravelPlan() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when + PlanCreateResponse actual = travelPlanService.createTravelPlan(request, memberAuth); + + // then + assertThat(actual.id()).isEqualTo(1L); + } + + @DisplayName("여행 계획 서비스는 지난 날짜로 여행 계획 생성 시 예외를 반환한다.") + @Test + void createTravelPlanWithInvalidStartDate() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .position(locationRequest) + .todos(Collections.EMPTY_LIST) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MIN) + .days(List.of(planDayRequest)) + .build(); + + // when & then= + assertThatThrownBy(() -> travelPlanService.createTravelPlan(request, memberAuth)) + .isInstanceOf(BadRequestException.class) + .hasMessage("지난 날짜에 대한 계획은 작성할 수 없습니다."); + } + + @DisplayName("당일에 시작하는 여행 계획을 생성할 수 있다") + @Test + void createTravelPlanStartsAtToday() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.now()) + .days(List.of(planDayRequest)) + .build(); + + // when & then= + assertThatCode(() -> travelPlanService.createTravelPlan(request, memberAuth)) + .doesNotThrowAnyException(); + } + + @DisplayName("여행 계획 서비스는 여행 계획 조회 시 상세 정보를 반환한다.") + @Test + void readTravelPlan() { + // given + Long id = testHelper.initTravelPlanTestData(author).getId(); + + // when + PlanResponse actual = travelPlanService.readTravelPlan(id, memberAuth); + + // then + assertThat(actual.id()).isEqualTo(id); + } + + @DisplayName("여행 계획 서비스는 존재하지 않는 여행 계획 조회 시 예외를 반환한다.") + @Test + void readTravelPlanWitNonExist() { + // given + databaseCleaner.executeTruncate(); + Long id = 1L; + + // when & then + assertThatThrownBy(() -> travelPlanService.readTravelPlan(id, memberAuth)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행 계획입니다."); + } + + @DisplayName("여행 계획 서비스는 작성자가 아닌 사용자가 조회 시 예외를 반환한다.") + @Test + void readTravelPlanWithNotAuthor() { + // given + Long id = testHelper.initTravelPlanTestData(author).getId(); + MemberAuth notAuthor = new MemberAuth(testHelper.initMemberTestData().getId()); + + // when & then + assertThatThrownBy(() -> travelPlanService.readTravelPlan(id, notAuthor)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("여행 계획 조회는 작성자만 가능합니다."); + } + + @DisplayName("여행 계획 서비스는 여행 계획 일자를 계산해 반환한다.") + @Test + void calculateTravelPeriod() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(author); + + // when + int actual = travelPlanService.calculateTravelPeriod(travelPlan); + + // then + assertThat(actual).isEqualTo(1); + } + + @DisplayName("여행 계획 서비스는 새로운 정보로 여행 계획을 수정한다.") + @Test + void updateTravelPlan() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(author); + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when + PlanCreateResponse updatedTravelPlan = travelPlanService.updateTravelPlan(travelPlan.getId(), memberAuth, + request); + + // then + assertThat(updatedTravelPlan.id()).isEqualTo(1L); + } + + @DisplayName("여행 계획 서비스는 존재하지 않는 여행 계획 수정 시 예외를 반환한다.") + @Test + void updateTravelPlanWitNonExist() { + // given + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when & then + assertThatThrownBy(() -> travelPlanService.updateTravelPlan(1L, memberAuth, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행 계획입니다."); + } + + @DisplayName("여행 계획 서비스는 작성자가 아닌 사용자가 수정 시 예외를 반환한다.") + @Test + void updateTravelPlanWithNotAuthor() { + // given + Long id = testHelper.initTravelPlanTestData(author).getId(); + MemberAuth notAuthor = new MemberAuth(testHelper.initMemberTestData().getId()); + PlanPositionRequest locationRequest = new PlanPositionRequest("37.5175896", "127.0867236"); + PlanPlaceRequest planPlaceRequest = PlanPlaceRequest.builder() + .placeName("잠실한강공원") + .todos(Collections.EMPTY_LIST) + .position(locationRequest) + .build(); + PlanDayRequest planDayRequest = new PlanDayRequest(List.of(planPlaceRequest)); + PlanRequest request = PlanRequest.builder() + .title("신나는 한강 여행") + .startDate(LocalDate.MAX) + .days(List.of(planDayRequest)) + .build(); + + // when & then + assertThatThrownBy(() -> travelPlanService.updateTravelPlan(id, notAuthor, request)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("여행 계획 수정은 작성자만 가능합니다."); + } + + @DisplayName("여행계획을 ID 기준으로 삭제할 수 있다.") + @Test + void deleteTravelPlanById() { + TravelPlan travelPlan = testHelper.initTravelPlanTestData(author); + travelPlanService.deleteByTravelPlanId(travelPlan.getId(), memberAuth); + + assertThat(travelPlanRepository.findById(travelPlan.getId())) + .isEmpty(); + } + + @DisplayName("여행 계획 서비스는 존재하지 않는 여행 계획 삭제 시 예외를 반환한다.") + @Test + void deleteTravelPlanWitNonExist() { + // given + databaseCleaner.executeTruncate(); + Long id = 1L; + + // when & then + assertThatThrownBy(() -> travelPlanService.deleteByTravelPlanId(id, memberAuth)) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행 계획입니다."); + } + + @DisplayName("여행 계획 서비스는 작성자가 아닌 사용자가 삭제 시 예외를 반환한다.") + @Test + void deleteTravelPlanWithNotAuthor() { + // given + Long id = testHelper.initTravelPlanTestData(author).getId(); + MemberAuth notAuthor = new MemberAuth(testHelper.initMemberTestData().getId()); + + // when & then + assertThatThrownBy(() -> travelPlanService.deleteByTravelPlanId(id, notAuthor)) + .isInstanceOf(ForbiddenException.class) + .hasMessage("여행 계획 삭제는 작성자만 가능합니다."); + } + + @DisplayName("여행 계획 서비스는 공유 키로 여행 계획을 조회할 수 있다") + @Test + void readTravelPlanByShareKey() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(author); + + // when + PlanResponse actual = travelPlanService.readTravelPlan(travelPlan.getShareKey()); + + // then + assertThat(actual.shareKey()).isEqualTo(travelPlan.getShareKey()); + } + + @DisplayName("여행 계획 서비스는 존재하지 않는 공유 키로 여행 계획을 조회할 경우 예외가 발생한다") + @Test + void readTravelPlanByInvalidShareKey() { + // given + TravelPlan travelPlan = testHelper.initTravelPlanTestData(author); + + // when & then + assertThatThrownBy(() -> travelPlanService.readTravelPlan(UUID.randomUUID())) + .isInstanceOf(BadRequestException.class) + .hasMessage("존재하지 않는 여행 계획입니다."); + } +} diff --git a/backend/src/test/java/kr/touroot/utils/DatabaseCleaner.java b/backend/src/test/java/kr/touroot/utils/DatabaseCleaner.java new file mode 100644 index 00000000..ada22125 --- /dev/null +++ b/backend/src/test/java/kr/touroot/utils/DatabaseCleaner.java @@ -0,0 +1,72 @@ +package kr.touroot.utils; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.metamodel.EntityType; +import jakarta.transaction.Transactional; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class DatabaseCleaner { + + public static final String CAMEL_CASE = "([a-z])([A-Z])"; + public static final String SNAKE_CASE = "$1_$2"; + private static final String TRUNCATE_TABLE = "TRUNCATE TABLE %s"; + private static final String ALTER_COLUMN_ID = "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1"; + public static final String INTEGRITY_FALSE = "SET REFERENTIAL_INTEGRITY FALSE"; + public static final String INTEGRITY_TRUE = "SET REFERENTIAL_INTEGRITY TRUE"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void findTableNames() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) + .map(DatabaseCleaner::convertCamelToSnake) + .toList(); + } + + private static String convertCamelToSnake(final EntityType e) { + return e.getName() + .replaceAll(CAMEL_CASE, SNAKE_CASE) + .toLowerCase(); + } + + @Transactional + public void executeTruncate() { + entityManager.clear(); + + disableIntegrity(); + for (String tableName : tableNames) { + truncateTable(tableName); + resetIdColumn(tableName); + } + enableIntegrity(); + } + + private void disableIntegrity() { + entityManager.createNativeQuery(INTEGRITY_FALSE) + .executeUpdate(); + } + + private void truncateTable(final String tableName) { + entityManager.createNativeQuery(String.format(TRUNCATE_TABLE, tableName)) + .executeUpdate(); + } + + private void resetIdColumn(final String tableName) { + entityManager.createNativeQuery(String.format(ALTER_COLUMN_ID, tableName)) + .executeUpdate(); + } + + private void enableIntegrity() { + entityManager.createNativeQuery(INTEGRITY_TRUE) + .executeUpdate(); + } +} diff --git a/backend/src/test/java/woowacourse/touroot/TourootApplicationTests.java b/backend/src/test/java/woowacourse/touroot/TourootApplicationTests.java deleted file mode 100644 index 8dde8ee9..00000000 --- a/backend/src/test/java/woowacourse/touroot/TourootApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package woowacourse.touroot; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TourootApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 00000000..0d119c92 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,42 @@ +oauth: + kakao: + user-information-request-uri: https://kapi.kakao.com/v2/user/me + access-token-request-uri: https://kauth.kakao.com/oauth/token + redirect-uri: http://localhost:3000/oauth + rest-api-key: test-api-key +security: + jwt: + token: + secret-key: test-TADG67STFSDAGSDFSG4567UTKJHFHSDFGSR231DF + expire-length: 1800000 # 30 min + refresh: + secret-key: test-TADG67STFSDAGSDFSG4567UTKJHFHSDFGSR231DF + expire-length: 1209600000 # 14 days +cloud: + aws: + s3: + bucket: techcourse-project-2024 + image-base-uri: https://dev.touroot.kr/ + base-storage-path: touroot/ + temporary-storage-path: temporary/ + image-storage-path: images/ +server: + port: 8081 +spring: + datasource: + url: jdbc:h2:mem:test + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create + defer-datasource-initialization: true + sql: + init: + mode: never