diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 201ad5570..e0d720750 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @Gyuhyeok99 @nayonsoso @wibaek +* @Gyuhyeok99 @nayonsoso @wibaek @whqtker @lsy1307 diff --git a/.github/workflows/stage-cd.yml b/.github/workflows/dev-cd.yml similarity index 51% rename from .github/workflows/stage-cd.yml rename to .github/workflows/dev-cd.yml index 41ff68b37..4269f9d46 100644 --- a/.github/workflows/stage-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -1,8 +1,8 @@ -name: "[STAGE] Build Gradle and Deploy" +name: "[DEV] Build Gradle and Deploy" on: push: - branches: [ "release" ] + branches: [ "develop" ] workflow_dispatch: jobs: @@ -38,38 +38,47 @@ jobs: - name: Copy jar file to remote uses: appleboy/scp-action@master with: - host: ${{ secrets.STAGE_HOST }} - username: ${{ secrets.STAGE_USERNAME }} - key: ${{ secrets.STAGE_PRIVATE_KEY }} + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} source: "./build/libs/*.jar" - target: "/home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage/" + target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" - name: Copy docker file to remote uses: appleboy/scp-action@master with: - host: ${{ secrets.STAGE_HOST }} - username: ${{ secrets.STAGE_USERNAME }} - key: ${{ secrets.STAGE_PRIVATE_KEY }} + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} source: "./Dockerfile" - target: "/home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage/" + target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" - name: Copy docker compose file to remote uses: appleboy/scp-action@master with: - host: ${{ secrets.STAGE_HOST }} - username: ${{ secrets.STAGE_USERNAME }} - key: ${{ secrets.STAGE_PRIVATE_KEY }} - source: "./docker-compose.stage.yml" - target: "/home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage/" + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} + source: "./docker-compose.dev.yml" + target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" + + - name: Copy alloy config file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} + source: "./docs/infra-config/config.alloy" + target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" - name: Run docker compose uses: appleboy/ssh-action@master with: - host: ${{ secrets.STAGE_HOST }} - username: ${{ secrets.STAGE_USERNAME }} - key: ${{ secrets.STAGE_PRIVATE_KEY }} + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} script_stop: true script: | - cd /home/${{ secrets.STAGE_USERNAME }}/solid-connect-stage + cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev docker compose down - docker compose -f docker-compose.stage.yml up -d --build + docker compose -f docker-compose.dev.yml up -d --build diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 9030c80f5..714aede30 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -61,7 +61,16 @@ jobs: key: ${{ secrets.PRIVATE_KEY }} source: "./docker-compose.prod.yml" target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" - + + - name: Copy alloy config file to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + source: "./docs/infra-config/config.alloy" + target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" + - name: Run docker compose uses: appleboy/ssh-action@master with: diff --git a/.gitignore b/.gitignore index 9f59fa8d9..d5df4047a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +logs/ ### STS ### .apt_generated diff --git a/README.md b/README.md index a07f6d77b..6cd3ba1a3 100644 Binary files a/README.md and b/README.md differ diff --git a/build.gradle b/build.gradle index b4267f41e..91cc2e77d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.1.5' id 'io.spring.dependency-management' version '1.1.4' + id 'org.flywaydb.flyway' version '9.16.3' } group = 'com.example' @@ -31,6 +32,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + runtimeOnly 'com.h2database:h2' // QueryDSL implementation 'io.github.openfeign.querydsl:querydsl-jpa:6.11' @@ -58,10 +60,14 @@ dependencies { testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:mysql' + testImplementation 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.awaitility:awaitility:4.2.0' // Etc implementation 'org.hibernate.validator:hibernate-validator' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.782' + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test', Test) { @@ -72,3 +78,21 @@ tasks.named('test', Test) { sourceSets { main.java.srcDirs += ['build/generated/sources/annotationProcessor/java/main'] } + +// build 단계에서 flyway 검증 +flyway { + url = 'jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1' + user = 'sa' + password = '' + locations = ['filesystem:src/main/resources/db/migration'] + validateMigrationNaming = true + ignoreMigrationPatterns = ['*:pending'] +} + +tasks.named('build') { + dependsOn 'flywayValidate' +} + +tasks.named('bootJar') { + dependsOn 'flywayValidate' +} diff --git a/docker-compose.stage.yml b/docker-compose.dev.yml similarity index 57% rename from docker-compose.stage.yml rename to docker-compose.dev.yml index 3a97a6411..a37ef4c55 100644 --- a/docker-compose.stage.yml +++ b/docker-compose.dev.yml @@ -19,15 +19,28 @@ services: - redis network_mode: host - solid-connection-stage: + solid-connection-dev: build: context: . dockerfile: Dockerfile - container_name: solid-connection-stage + container_name: solid-connection-dev ports: - "8080:8080" environment: - - SPRING_PROFILES_ACTIVE=stage + - SPRING_PROFILES_ACTIVE=dev + volumes: + - ./logs:/var/log/spring depends_on: - redis network_mode: host + + alloy: + image: grafana/alloy:latest + container_name: alloy + ports: + - "12345:12345" + volumes: + - ./logs:/var/log/spring + - ./docs/config.alloy:/etc/alloy/config.alloy:ro + environment: + - ALLOY_ENV=dev diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9517a07aa..30b0c9fc1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,5 +28,18 @@ services: - SPRING_PROFILES_ACTIVE=prod - SPRING_DATA_REDIS_HOST=redis - SPRING_DATA_REDIS_PORT=6379 + volumes: + - ./logs:/var/log/spring depends_on: - redis + + alloy: + image: grafana/alloy:latest + container_name: alloy + ports: + - "12345:12345" + volumes: + - ./logs:/var/log/spring + - ./docs/config.alloy:/etc/alloy/config.alloy:ro + environment: + - ALLOY_ENV=production diff --git a/docs/code-style/solid-connection-intellij-scheme.xml b/docs/code-style/solid-connection-intellij-scheme.xml new file mode 100644 index 000000000..3bcaefe90 --- /dev/null +++ b/docs/code-style/solid-connection-intellij-scheme.xml @@ -0,0 +1,595 @@ + + + + \ No newline at end of file diff --git a/docs/infra-config/config.alloy b/docs/infra-config/config.alloy new file mode 100644 index 000000000..bd3aedf9a --- /dev/null +++ b/docs/infra-config/config.alloy @@ -0,0 +1,37 @@ +livedebugging { + enabled = true +} + +logging { + level = "info" + format = "logfmt" +} + +local.file_match "spring_logs" { + path_targets = [{ __path__ = "/var/log/spring/*.log" }] // 서비스 로그 파일 경로 +} + +loki.source.file "spring_source" { + targets = local.file_match.spring_logs.targets // 위에서 정의한 로그 파일 경로 사용 + forward_to = [loki.process.spring_labels.receiver] // 읽은 로그를 처리 단계로 전달 +} + +loki.process "spring_labels" { + forward_to = [loki.write.grafana_loki.receiver] // 처리된 로그를 Loki로 전송 + + stage.static_labels { + values = { + service = "backend", + env = sys.env("ALLOY_ENV"), + } + } +} + +loki.write "grafana_loki" { + endpoint { + url = "http://monitor.solid-connection.com:3100/loki/api/v1/push" + tenant_id = "fake" // Loki 테넌트 ID (싱글 테넌시이기에 fake로 설정) + batch_wait = "1s" // 로그 배치 전송 대기 시간 + batch_size = "1MB" // 로그 배치 크기 + } +} diff --git a/docs/nginx.conf b/docs/infra-config/nginx.conf similarity index 100% rename from docs/nginx.conf rename to docs/infra-config/nginx.conf diff --git a/local_compose_down.sh b/docs/script/local_compose_down.sh similarity index 63% rename from local_compose_down.sh rename to docs/script/local_compose_down.sh index 32792e490..ceb9df3e1 100755 --- a/local_compose_down.sh +++ b/docs/script/local_compose_down.sh @@ -2,8 +2,10 @@ set -e +BASE_DIR="$(cd "$(dirname "$0")/../.." && pwd)" + echo "Stopping all docker containers..." -docker compose -f docker-compose.local.yml down +docker compose -f "${BASE_DIR}/docker-compose.local.yml" down echo "Pruning unused Docker images..." docker image prune -f diff --git a/docs/script/local_compose_up.sh b/docs/script/local_compose_up.sh new file mode 100755 index 000000000..df36cb0ea --- /dev/null +++ b/docs/script/local_compose_up.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# 명령이 0이 아닌 종료값을 가질때 즉시 종료 +set -e + +BASE_DIR="$(cd "$(dirname "$0")/../.." && pwd)" + +if [ ! -d "${BASE_DIR}/mysql_data_local" ]; then + echo "mysql_data_local 디렉토리가 없습니다. ${BASE_DIR}/mysql_data_local 디렉토리를 생성합니다." + mkdir -p "${BASE_DIR}/mysql_data_local" +fi + +if [ ! -d "${BASE_DIR}/redis_data_local" ]; then + echo "redis_data_local 디렉토리가 없습니다. ${BASE_DIR}/redis_data_local 디렉토리를 생성합니다." + mkdir -p "${BASE_DIR}/redis_data_local" +fi + +echo "Starting all docker containers..." +docker compose -f "${BASE_DIR}/docker-compose.local.yml" up -d + +echo "Pruning unused Docker images..." +docker image prune -f + +echo "Containers are up and running." +docker compose ps -a diff --git a/local_compose_up.sh b/local_compose_up.sh deleted file mode 100755 index 400861e57..000000000 --- a/local_compose_up.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# 명령이 0이 아닌 종료값을 가질때 즉시 종료 -set -e - -if [ ! -d "mysql_data_local" ]; then - echo "mysql_data_local 디렉토리가 없습니다. 디렉토리를 생성합니다." - mkdir -p mysql_data_local -fi - -if [ ! -d "redis_data_local" ]; then - echo "redis_data_local 디렉토리가 없습니다. 디렉토리를 생성합니다." - mkdir -p redis_data_local -fi - -echo "Starting all docker containers..." -docker compose -f docker-compose.local.yml up -d - -echo "Pruning unused Docker images..." -docker image prune -f - -echo "Containers are up and running." -docker compose ps -a diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminScoreController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminScoreController.java index 47bac37e1..d2869e4d3 100644 --- a/src/main/java/com/example/solidconnection/admin/controller/AdminScoreController.java +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminScoreController.java @@ -9,7 +9,7 @@ import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.admin.service.AdminGpaScoreService; import com.example.solidconnection.admin.service.AdminLanguageTestScoreService; -import com.example.solidconnection.custom.response.PageResponse; +import com.example.solidconnection.common.response.PageResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/example/solidconnection/admin/dto/GpaResponse.java b/src/main/java/com/example/solidconnection/admin/dto/GpaResponse.java index 564bc724b..36d66820d 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/GpaResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/GpaResponse.java @@ -5,4 +5,5 @@ public record GpaResponse( double gpaCriteria, String gpaReportUrl ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreResponse.java b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreResponse.java index 5f37e823b..524142feb 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.admin.dto; +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.type.VerifyStatus; public record GpaScoreResponse( long id, @@ -10,6 +10,7 @@ public record GpaScoreResponse( VerifyStatus verifyStatus, String rejectedReason ) { + public static GpaScoreResponse from(GpaScore gpaScore) { return new GpaScoreResponse( gpaScore.getId(), diff --git a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreSearchResponse.java b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreSearchResponse.java index 2da39fb88..30bf26469 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreSearchResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreSearchResponse.java @@ -4,4 +4,5 @@ public record GpaScoreSearchResponse( GpaScoreStatusResponse gpaScoreStatusResponse, SiteUserResponse siteUserResponse ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreStatusResponse.java b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreStatusResponse.java index 49afbd4ed..00937eb2d 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreStatusResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreStatusResponse.java @@ -1,7 +1,6 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.type.VerifyStatus; - +import com.example.solidconnection.common.VerifyStatus; import java.time.ZonedDateTime; public record GpaScoreStatusResponse( @@ -12,4 +11,5 @@ public record GpaScoreStatusResponse( ZonedDateTime createdAt, ZonedDateTime updatedAt ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreUpdateRequest.java index b7fe58c71..85d0d2472 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/GpaScoreUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/dto/GpaScoreUpdateRequest.java @@ -1,7 +1,7 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.custom.validation.annotation.RejectedReasonRequired; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.application.dto.validation.RejectedReasonRequired; +import com.example.solidconnection.common.VerifyStatus; import jakarta.validation.constraints.NotNull; @RejectedReasonRequired @@ -18,4 +18,5 @@ public record GpaScoreUpdateRequest( String rejectedReason ) implements ScoreUpdateRequest { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestResponse.java b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestResponse.java index c91fc68c3..2740a03f3 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestResponse.java @@ -1,10 +1,11 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageTestType; public record LanguageTestResponse( LanguageTestType languageTestType, String languageTestScore, String languageTestReportUrl ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreResponse.java b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreResponse.java index aee435c9c..b8c2d2b73 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreResponse.java @@ -1,8 +1,8 @@ package com.example.solidconnection.admin.dto; +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.LanguageTestType; public record LanguageTestScoreResponse( long id, @@ -11,6 +11,7 @@ public record LanguageTestScoreResponse( VerifyStatus verifyStatus, String rejectedReason ) { + public static LanguageTestScoreResponse from(LanguageTestScore languageTestScore) { return new LanguageTestScoreResponse( languageTestScore.getId(), diff --git a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreSearchResponse.java b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreSearchResponse.java index 0e1830f66..ba4e27a7d 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreSearchResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreSearchResponse.java @@ -4,4 +4,5 @@ public record LanguageTestScoreSearchResponse( LanguageTestScoreStatusResponse languageTestScoreStatusResponse, SiteUserResponse siteUserResponse ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreStatusResponse.java b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreStatusResponse.java index c852b5b2a..b2bd29fb9 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreStatusResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreStatusResponse.java @@ -1,7 +1,6 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.type.VerifyStatus; - +import com.example.solidconnection.common.VerifyStatus; import java.time.ZonedDateTime; public record LanguageTestScoreStatusResponse( @@ -12,4 +11,5 @@ public record LanguageTestScoreStatusResponse( ZonedDateTime createdAt, ZonedDateTime updatedAt ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreUpdateRequest.java index 3e76e0c93..150cd02b7 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/dto/LanguageTestScoreUpdateRequest.java @@ -1,8 +1,8 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.custom.validation.annotation.RejectedReasonRequired; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.application.dto.validation.RejectedReasonRequired; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.university.domain.LanguageTestType; import jakarta.validation.constraints.NotNull; @RejectedReasonRequired @@ -19,4 +19,5 @@ public record LanguageTestScoreUpdateRequest( String rejectedReason ) implements ScoreUpdateRequest { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/ScoreSearchCondition.java b/src/main/java/com/example/solidconnection/admin/dto/ScoreSearchCondition.java index 2e94628e6..2efdb476b 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/ScoreSearchCondition.java +++ b/src/main/java/com/example/solidconnection/admin/dto/ScoreSearchCondition.java @@ -1,11 +1,11 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.type.VerifyStatus; - +import com.example.solidconnection.common.VerifyStatus; import java.time.LocalDate; public record ScoreSearchCondition( VerifyStatus verifyStatus, String nickname, LocalDate createdAt) { + } diff --git a/src/main/java/com/example/solidconnection/admin/dto/ScoreUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/dto/ScoreUpdateRequest.java index 184f76100..8e04b06de 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/ScoreUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/dto/ScoreUpdateRequest.java @@ -1,8 +1,10 @@ package com.example.solidconnection.admin.dto; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.common.VerifyStatus; public interface ScoreUpdateRequest { + VerifyStatus verifyStatus(); + String rejectedReason(); } diff --git a/src/main/java/com/example/solidconnection/admin/dto/SiteUserResponse.java b/src/main/java/com/example/solidconnection/admin/dto/SiteUserResponse.java index 1b62f262f..0b6d23816 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/SiteUserResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/SiteUserResponse.java @@ -5,4 +5,5 @@ public record SiteUserResponse( String nickname, String profileImageUrl ) { + } diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java index c761ff485..717f2a963 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java @@ -1,22 +1,22 @@ package com.example.solidconnection.admin.service; +import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; + import com.example.solidconnection.admin.dto.GpaScoreResponse; import com.example.solidconnection.admin.dto.GpaScoreSearchResponse; import com.example.solidconnection.admin.dto.GpaScoreUpdateRequest; import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.type.VerifyStatus; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static com.example.solidconnection.custom.exception.ErrorCode.GPA_SCORE_NOT_FOUND; - @RequiredArgsConstructor @Service public class AdminGpaScoreService { diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java index 380ef02c6..0aa4a859d 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java @@ -1,22 +1,22 @@ package com.example.solidconnection.admin.service; +import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; + import com.example.solidconnection.admin.dto.LanguageTestScoreResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreSearchResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreUpdateRequest; import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; -import com.example.solidconnection.type.VerifyStatus; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static com.example.solidconnection.custom.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; - @RequiredArgsConstructor @Service public class AdminLanguageTestScoreService { diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index 36c7d6af2..07571d060 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -5,9 +5,9 @@ import com.example.solidconnection.application.dto.ApplyRequest; import com.example.solidconnection.application.service.ApplicationQueryService; import com.example.solidconnection.application.service.ApplicationSubmissionService; -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.custom.security.annotation.RequireAdminAccess; -import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -30,34 +30,34 @@ public class ApplicationController { // 지원서 제출하기 api @PostMapping public ResponseEntity apply( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @Valid @RequestBody ApplyRequest applyRequest ) { - ApplicationSubmissionResponse applicationSubmissionResponse = applicationSubmissionService.apply(siteUser, applyRequest); + ApplicationSubmissionResponse applicationSubmissionResponse = applicationSubmissionService.apply(siteUserId, applyRequest); return ResponseEntity .status(HttpStatus.OK) .body(applicationSubmissionResponse); } - @RequireAdminAccess + @RequireRoleAccess(roles = {Role.ADMIN}) @GetMapping public ResponseEntity getApplicants( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @RequestParam(required = false, defaultValue = "") String region, @RequestParam(required = false, defaultValue = "") String keyword ) { - applicationQueryService.validateSiteUserCanViewApplicants(siteUser); - ApplicationsResponse result = applicationQueryService.getApplicants(siteUser, region, keyword); + applicationQueryService.validateSiteUserCanViewApplicants(siteUserId); + ApplicationsResponse result = applicationQueryService.getApplicants(siteUserId, region, keyword); return ResponseEntity .ok(result); } @GetMapping("/competitors") public ResponseEntity getApplicantsForUserCompetitors( - @AuthorizedUser SiteUser siteUser + @AuthorizedUser long siteUserId ) { - applicationQueryService.validateSiteUserCanViewApplicants(siteUser); - ApplicationsResponse result = applicationQueryService.getApplicantsByUserApplications(siteUser); + applicationQueryService.validateSiteUserCanViewApplicants(siteUserId); + ApplicationsResponse result = applicationQueryService.getApplicantsByUserApplications(siteUserId); return ResponseEntity .ok(result); } diff --git a/src/main/java/com/example/solidconnection/application/domain/Application.java b/src/main/java/com/example/solidconnection/application/domain/Application.java index 6caa75331..72eb0068b 100644 --- a/src/main/java/com/example/solidconnection/application/domain/Application.java +++ b/src/main/java/com/example/solidconnection/application/domain/Application.java @@ -1,31 +1,40 @@ package com.example.solidconnection.application.domain; +import static com.example.solidconnection.common.VerifyStatus.PENDING; + +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.VerifyStatus; -import com.example.solidconnection.university.domain.UniversityInfoForApply; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.Index; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import static com.example.solidconnection.type.VerifyStatus.PENDING; - @Getter @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) @DynamicUpdate @DynamicInsert @Entity +@Table(indexes = { + @Index(name = "idx_app_user_term_delete", + columnList = "site_user_id, term, is_delete"), + @Index(name = "idx_app_first_choice_search", + columnList = "verify_status, term, is_delete, first_choice_university_info_for_apply_id"), + @Index(name = "idx_app_second_choice_search", + columnList = "verify_status, term, is_delete, second_choice_university_info_for_apply_id"), + @Index(name = "idx_app_third_choice_search", + columnList = "verify_status, term, is_delete, third_choice_university_info_for_apply_id") +}) public class Application { @Id @@ -39,40 +48,40 @@ public class Application { private LanguageTest languageTest; @Setter - @Column(columnDefinition = "varchar(50) not null default 'PENDING'") + @Column(name = "verify_status", nullable = false) @Enumerated(EnumType.STRING) - private VerifyStatus verifyStatus; + private VerifyStatus verifyStatus = VerifyStatus.PENDING; - @Column(length = 100) + @Column(length = 100, name = "nickname_for_apply") private String nicknameForApply; - @Column(columnDefinition = "int not null default 1") + @Column(columnDefinition = "int not null default 1", name = "update_count") private Integer updateCount; - @Column(length = 50, nullable = false) + @Column(length = 50, nullable = false, name = "term") private String term; - @Column + @Column(name = "is_delete") private boolean isDelete = false; - @ManyToOne(fetch = FetchType.LAZY) - private UniversityInfoForApply firstChoiceUniversity; + @Column(nullable = false, name = "first_choice_university_info_for_apply_id") + private long firstChoiceUnivApplyInfoId; - @ManyToOne(fetch = FetchType.LAZY) - private UniversityInfoForApply secondChoiceUniversity; + @Column(name = "second_choice_university_info_for_apply_id") + private Long secondChoiceUnivApplyInfoId; - @ManyToOne(fetch = FetchType.LAZY) - private UniversityInfoForApply thirdChoiceUniversity; + @Column(name = "third_choice_university_info_for_apply_id") + private Long thirdChoiceUnivApplyInfoId; - @ManyToOne(fetch = FetchType.LAZY) - private SiteUser siteUser; + @Column(name = "site_user_id") + private long siteUserId; public Application( SiteUser siteUser, Gpa gpa, LanguageTest languageTest, String term) { - this.siteUser = siteUser; + this.siteUserId = siteUser.getId(); this.gpa = gpa; this.languageTest = languageTest; this.term = term; @@ -86,18 +95,18 @@ public Application( LanguageTest languageTest, String term, Integer updateCount, - UniversityInfoForApply firstChoiceUniversity, - UniversityInfoForApply secondChoiceUniversity, - UniversityInfoForApply thirdChoiceUniversity, + long firstChoiceUnivApplyInfoId, + Long secondChoiceUnivApplyInfoId, + Long thirdChoiceUnivApplyInfoId, String nicknameForApply) { - this.siteUser = siteUser; + this.siteUserId = siteUser.getId(); this.gpa = gpa; this.languageTest = languageTest; this.term = term; this.updateCount = updateCount; - this.firstChoiceUniversity = firstChoiceUniversity; - this.secondChoiceUniversity = secondChoiceUniversity; - this.thirdChoiceUniversity = thirdChoiceUniversity; + this.firstChoiceUnivApplyInfoId = firstChoiceUnivApplyInfoId; + this.secondChoiceUnivApplyInfoId = secondChoiceUnivApplyInfoId; + this.thirdChoiceUnivApplyInfoId = thirdChoiceUnivApplyInfoId; this.nicknameForApply = nicknameForApply; this.verifyStatus = PENDING; } @@ -107,18 +116,18 @@ public Application( Gpa gpa, LanguageTest languageTest, String term, - UniversityInfoForApply firstChoiceUniversity, - UniversityInfoForApply secondChoiceUniversity, - UniversityInfoForApply thirdChoiceUniversity, + long firstChoiceUnivApplyInfoId, + Long secondChoiceUnivApplyInfoId, + Long thirdChoiceUnivApplyInfoId, String nicknameForApply) { - this.siteUser = siteUser; + this.siteUserId = siteUser.getId(); this.gpa = gpa; this.languageTest = languageTest; this.term = term; this.updateCount = 1; - this.firstChoiceUniversity = firstChoiceUniversity; - this.secondChoiceUniversity = secondChoiceUniversity; - this.thirdChoiceUniversity = thirdChoiceUniversity; + this.firstChoiceUnivApplyInfoId = firstChoiceUnivApplyInfoId; + this.secondChoiceUnivApplyInfoId = secondChoiceUnivApplyInfoId; + this.thirdChoiceUnivApplyInfoId = thirdChoiceUnivApplyInfoId; this.nicknameForApply = nicknameForApply; this.verifyStatus = PENDING; } @@ -126,18 +135,4 @@ public Application( public void setIsDeleteTrue() { this.isDelete = true; } - - public void updateUniversityChoice( - UniversityInfoForApply firstChoiceUniversity, - UniversityInfoForApply secondChoiceUniversity, - UniversityInfoForApply thirdChoiceUniversity, - String nicknameForApply) { - if (this.firstChoiceUniversity != null) { - this.updateCount++; - } - this.firstChoiceUniversity = firstChoiceUniversity; - this.secondChoiceUniversity = secondChoiceUniversity; - this.thirdChoiceUniversity = thirdChoiceUniversity; - this.nicknameForApply = nicknameForApply; - } } diff --git a/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java b/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java index 4295372d4..4a0544ea7 100644 --- a/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java +++ b/src/main/java/com/example/solidconnection/application/domain/LanguageTest.java @@ -1,6 +1,6 @@ package com.example.solidconnection.application.domain; -import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageTestType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java index 9835491b1..c6161a134 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicantResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.application.dto; import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageTestType; public record ApplicantResponse( String nicknameForApply, diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java new file mode 100644 index 000000000..fdb9c357c --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.application.dto; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import java.util.List; +import java.util.Objects; + +public record ApplicantsResponse( + String koreanName, + int studentCapacity, + String region, + String country, + List applicants) { + + public static ApplicantsResponse of(UnivApplyInfo univApplyInfo, List applications, SiteUser siteUser) { + return new ApplicantsResponse( + univApplyInfo.getKoreanName(), + univApplyInfo.getStudentCapacity(), + univApplyInfo.getUniversity().getRegion().getKoreanName(), + univApplyInfo.getUniversity().getCountry().getKoreanName(), + applications.stream() + .map(application -> ApplicantResponse.of(application, isUsers(application, siteUser))) + .toList()); + } + + private static boolean isUsers(Application application, SiteUser siteUser) { + return Objects.equals(application.getSiteUserId(), siteUser.getId()); + } +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java index e90994c37..a2636c135 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicationSubmissionResponse.java @@ -5,6 +5,7 @@ public record ApplicationSubmissionResponse( int applyCount ) { + public static ApplicationSubmissionResponse from(Application application) { return new ApplicationSubmissionResponse(application.getUpdateCount()); } diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java index a3429c1ef..657c7c2c3 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java @@ -3,7 +3,8 @@ import java.util.List; public record ApplicationsResponse( - List firstChoice, - List secondChoice, - List thirdChoice) { + List firstChoice, + List secondChoice, + List thirdChoice) { + } diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java b/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java index 7c4da1c99..c50252c00 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.application.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -12,6 +13,8 @@ public record ApplyRequest( Long languageTestScoreId, @Valid - UniversityChoiceRequest universityChoiceRequest + @JsonProperty("universityChoiceRequest") + UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest ) { + } diff --git a/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java b/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java new file mode 100644 index 000000000..449b1ca2c --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.application.dto; + +import com.example.solidconnection.university.dto.validation.ValidUnivApplyInfoChoice; +import com.fasterxml.jackson.annotation.JsonProperty; + +@ValidUnivApplyInfoChoice +public record UnivApplyInfoChoiceRequest( + + @JsonProperty("firstChoiceUniversityId") + Long firstChoiceUnivApplyInfoId, + + @JsonProperty("secondChoiceUniversityId") + Long secondChoiceUnivApplyInfoId, + + @JsonProperty("thirdChoiceUniversityId") + Long thirdChoiceUnivApplyInfoId) { + +} diff --git a/src/main/java/com/example/solidconnection/application/dto/UniversityApplicantsResponse.java b/src/main/java/com/example/solidconnection/application/dto/UniversityApplicantsResponse.java deleted file mode 100644 index 1d3415003..000000000 --- a/src/main/java/com/example/solidconnection/application/dto/UniversityApplicantsResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.solidconnection.application.dto; - -import com.example.solidconnection.university.domain.UniversityInfoForApply; - -import java.util.List; - -public record UniversityApplicantsResponse( - String koreanName, - int studentCapacity, - String region, - String country, - List applicants) { - - public static UniversityApplicantsResponse of(UniversityInfoForApply universityInfoForApply, List applicant) { - return new UniversityApplicantsResponse( - universityInfoForApply.getKoreanName(), - universityInfoForApply.getStudentCapacity(), - universityInfoForApply.getUniversity().getRegion().getKoreanName(), - universityInfoForApply.getUniversity().getCountry().getKoreanName(), - applicant); - } -} diff --git a/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java b/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java deleted file mode 100644 index d219dbc2e..000000000 --- a/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.solidconnection.application.dto; - -import com.example.solidconnection.custom.validation.annotation.ValidUniversityChoice; - -@ValidUniversityChoice -public record UniversityChoiceRequest( - Long firstChoiceUniversityId, - Long secondChoiceUniversityId, - Long thirdChoiceUniversityId) { -} diff --git a/src/main/java/com/example/solidconnection/custom/validation/annotation/RejectedReasonRequired.java b/src/main/java/com/example/solidconnection/application/dto/validation/RejectedReasonRequired.java similarity index 78% rename from src/main/java/com/example/solidconnection/custom/validation/annotation/RejectedReasonRequired.java rename to src/main/java/com/example/solidconnection/application/dto/validation/RejectedReasonRequired.java index 4ae4a6618..7f00291ba 100644 --- a/src/main/java/com/example/solidconnection/custom/validation/annotation/RejectedReasonRequired.java +++ b/src/main/java/com/example/solidconnection/application/dto/validation/RejectedReasonRequired.java @@ -1,9 +1,7 @@ -package com.example.solidconnection.custom.validation.annotation; +package com.example.solidconnection.application.dto.validation; -import com.example.solidconnection.custom.validation.validator.RejectedReasonValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -15,6 +13,8 @@ public @interface RejectedReasonRequired { String message() default "거절 사유 입력값이 올바르지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; } diff --git a/src/main/java/com/example/solidconnection/custom/validation/validator/RejectedReasonValidator.java b/src/main/java/com/example/solidconnection/application/dto/validation/RejectedReasonValidator.java similarity index 82% rename from src/main/java/com/example/solidconnection/custom/validation/validator/RejectedReasonValidator.java rename to src/main/java/com/example/solidconnection/application/dto/validation/RejectedReasonValidator.java index c975a0a0a..85e6ecc78 100644 --- a/src/main/java/com/example/solidconnection/custom/validation/validator/RejectedReasonValidator.java +++ b/src/main/java/com/example/solidconnection/application/dto/validation/RejectedReasonValidator.java @@ -1,14 +1,13 @@ -package com.example.solidconnection.custom.validation.validator; +package com.example.solidconnection.application.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.REJECTED_REASON_REQUIRED; import com.example.solidconnection.admin.dto.ScoreUpdateRequest; -import com.example.solidconnection.custom.validation.annotation.RejectedReasonRequired; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.common.VerifyStatus; import io.micrometer.common.util.StringUtils; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; -import static com.example.solidconnection.custom.exception.ErrorCode.REJECTED_REASON_REQUIRED; - public class RejectedReasonValidator implements ConstraintValidator { private static final String REJECTED_REASON = "rejectedReason"; diff --git a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java index 5aeb972bf..3916f37db 100644 --- a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java @@ -1,44 +1,43 @@ package com.example.solidconnection.application.repository; +import static com.example.solidconnection.common.exception.ErrorCode.APPLICATION_NOT_FOUND; + import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.VerifyStatus; -import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; -import static com.example.solidconnection.custom.exception.ErrorCode.APPLICATION_NOT_FOUND; - -@Repository public interface ApplicationRepository extends JpaRepository { boolean existsByNicknameForApply(String nicknameForApply); - List findAllByFirstChoiceUniversityAndVerifyStatusAndTermAndIsDeleteFalse( - UniversityInfoForApply firstChoiceUniversity, VerifyStatus verifyStatus, String term); - - List findAllBySecondChoiceUniversityAndVerifyStatusAndTermAndIsDeleteFalse( - UniversityInfoForApply secondChoiceUniversity, VerifyStatus verifyStatus, String term); - - List findAllByThirdChoiceUniversityAndVerifyStatusAndTermAndIsDeleteFalse( - UniversityInfoForApply thirdChoiceUniversity, VerifyStatus verifyStatus, String term); - @Query(""" - SELECT a FROM Application a - WHERE a.siteUser = :siteUser - AND a.term = :term - AND a.isDelete = false - """) - Optional findBySiteUserAndTerm(@Param("siteUser") SiteUser siteUser, @Param("term") String term); + SELECT a + FROM Application a + WHERE (a.firstChoiceUnivApplyInfoId IN :univApplyInfoIds + OR a.secondChoiceUnivApplyInfoId IN :univApplyInfoIds + OR a.thirdChoiceUnivApplyInfoId IN :univApplyInfoIds) + AND a.verifyStatus = :status + AND a.term = :term + AND a.isDelete = false + """) + List findAllByUnivApplyInfoIds(@Param("univApplyInfoIds") List univApplyInfoIds, @Param("status") VerifyStatus status, @Param("term") String term); - default Application getApplicationBySiteUserAndTerm(SiteUser siteUser, String term) { - return findBySiteUserAndTerm(siteUser, term) + @Query(""" + SELECT a + FROM Application a + WHERE a.siteUserId = :siteUserId + AND a.term = :term + AND a.isDelete = false + """) + Optional findBySiteUserIdAndTerm(@Param("siteUserId") long siteUserId, @Param("term") String term); + + default Application getApplicationBySiteUserIdAndTerm(long siteUserId, String term) { + return findBySiteUserIdAndTerm(siteUserId, term) .orElseThrow(() -> new CustomException(APPLICATION_NOT_FOUND)); } } diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java index 157c6adcd..7eba19de3 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java @@ -1,134 +1,136 @@ package com.example.solidconnection.application.service; +import static com.example.solidconnection.common.exception.ErrorCode.APPLICATION_NOT_APPROVED; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.application.dto.ApplicantResponse; +import com.example.solidconnection.application.dto.ApplicantsResponse; import com.example.solidconnection.application.dto.ApplicationsResponse; -import com.example.solidconnection.application.dto.UniversityApplicantsResponse; import com.example.solidconnection.application.repository.ApplicationRepository; -import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.VerifyStatus; -import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import com.example.solidconnection.university.repository.custom.UniversityFilterRepositoryImpl; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import com.example.solidconnection.university.repository.custom.UnivApplyInfoFilterRepositoryImpl; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; - -import static com.example.solidconnection.custom.exception.ErrorCode.APPLICATION_NOT_APPROVED; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class ApplicationQueryService { private final ApplicationRepository applicationRepository; - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final UniversityFilterRepositoryImpl universityFilterRepository; + private final UnivApplyInfoRepository univApplyInfoRepository; + private final UnivApplyInfoFilterRepositoryImpl universityFilterRepository; + private final SiteUserRepository siteUserRepository; @Value("${university.term}") public String term; - /* - * 다른 지원자들의 성적을 조회한다. - * - 유저가 다른 지원자들을 볼 수 있는지 검증한다. - * - 지역과 키워드를 통해 대학을 필터링한다. - * - 지역은 영어 대문자로 받는다 e.g. ASIA - * - 1지망, 2지망 지원자들을 조회한다. - * */ + // todo: 캐싱 정책 변경 시 수정 필요 @Transactional(readOnly = true) - // todo: 임시로 단일 키로 캐시 적용. 추후 캐싱 전략 재검토 필요. - @ThunderingHerdCaching(key = "applications:all", cacheManager = "customCacheManager", ttlSec = 86400) - public ApplicationsResponse getApplicants(SiteUser siteUser, String regionCode, String keyword) { - // 국가와 키워드와 지역을 통해 대학을 필터링한다. - List universities - = universityFilterRepository.findByRegionCodeAndKeywords(regionCode, List.of(keyword)); - - // 1지망, 2지망, 3지망 지원자들을 조회한다. - List firstChoiceApplicants = getFirstChoiceApplicants(universities, siteUser, term); - List secondChoiceApplicants = getSecondChoiceApplicants(universities, siteUser, term); - List thirdChoiceApplicants = getThirdChoiceApplicants(universities, siteUser, term); - return new ApplicationsResponse(firstChoiceApplicants, secondChoiceApplicants, thirdChoiceApplicants); + public ApplicationsResponse getApplicants(long siteUserId, String regionCode, String keyword) { + // 1. 대학 지원 정보 필터링 (regionCode, keyword) + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + List univApplyInfos = universityFilterRepository.findAllByRegionCodeAndKeywords(regionCode, List.of(keyword)); + if (univApplyInfos.isEmpty()) { + return new ApplicationsResponse(List.of(), List.of(), List.of()); + } + // 2. 조건에 맞는 모든 Application 한 번에 조회 + List univApplyInfoIds = univApplyInfos.stream() + .map(UnivApplyInfo::getId) + .toList(); + List applications = applicationRepository.findAllByUnivApplyInfoIds(univApplyInfoIds, VerifyStatus.APPROVED, term); + // 3. 지원서 분류 및 DTO 변환 + return classifyApplicationsByChoice(univApplyInfos, applications, siteUser); } @Transactional(readOnly = true) - public ApplicationsResponse getApplicantsByUserApplications(SiteUser siteUser) { - Application userLatestApplication = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term); - List userAppliedUniversities = Arrays.asList( - Optional.ofNullable(userLatestApplication.getFirstChoiceUniversity()) - .map(UniversityInfoForApply::getUniversity) - .orElse(null), - Optional.ofNullable(userLatestApplication.getSecondChoiceUniversity()) - .map(UniversityInfoForApply::getUniversity) - .orElse(null), - Optional.ofNullable(userLatestApplication.getThirdChoiceUniversity()) - .map(UniversityInfoForApply::getUniversity) - .orElse(null) - ).stream() + public ApplicationsResponse getApplicantsByUserApplications(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + Application userLatestApplication = applicationRepository.getApplicationBySiteUserIdAndTerm(siteUser.getId(), term); + + List univApplyInfoIds = Stream.of( + userLatestApplication.getFirstChoiceUnivApplyInfoId(), + userLatestApplication.getSecondChoiceUnivApplyInfoId(), + userLatestApplication.getThirdChoiceUnivApplyInfoId() + ) .filter(Objects::nonNull) .collect(Collectors.toList()); - List firstChoiceApplicants = getFirstChoiceApplicants(userAppliedUniversities, siteUser, term); - List secondChoiceApplicants = getSecondChoiceApplicants(userAppliedUniversities, siteUser, term); - List thirdChoiceApplicants = getThirdChoiceApplicants(userAppliedUniversities, siteUser, term); - return new ApplicationsResponse(firstChoiceApplicants, secondChoiceApplicants, thirdChoiceApplicants); - } - - // 학기별로 상태가 관리된다. - // 금학기에 지원이력이 있는 사용자만 지원정보를 확인할 수 있도록 한다. - @Transactional(readOnly = true) - public void validateSiteUserCanViewApplicants(SiteUser siteUser) { - VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term).getVerifyStatus(); - if (verifyStatus != VerifyStatus.APPROVED) { - throw new CustomException(APPLICATION_NOT_APPROVED); + if (univApplyInfoIds.isEmpty()) { + return new ApplicationsResponse(List.of(), List.of(), List.of()); } - } - private List getFirstChoiceApplicants(List universities, SiteUser siteUser, String term) { - return getApplicantsByChoice( - universities, - siteUser, - uia -> applicationRepository.findAllByFirstChoiceUniversityAndVerifyStatusAndTermAndIsDeleteFalse(uia, VerifyStatus.APPROVED, term) - ); + List applications = applicationRepository.findAllByUnivApplyInfoIds(univApplyInfoIds, VerifyStatus.APPROVED, term); + List univApplyInfos = univApplyInfoRepository.findAllByIds(univApplyInfoIds); + + return classifyApplicationsByChoice(univApplyInfos, applications, siteUser); } - private List getSecondChoiceApplicants(List universities, SiteUser siteUser, String term) { - return getApplicantsByChoice( - universities, - siteUser, - uia -> applicationRepository.findAllBySecondChoiceUniversityAndVerifyStatusAndTermAndIsDeleteFalse(uia, VerifyStatus.APPROVED, term) - ); + private ApplicationsResponse classifyApplicationsByChoice( + List univApplyInfos, + List applications, + SiteUser siteUser) { + Map> firstChoiceMap = createChoiceMap(applications, Application::getFirstChoiceUnivApplyInfoId); + Map> secondChoiceMap = createChoiceMap(applications, Application::getSecondChoiceUnivApplyInfoId); + Map> thirdChoiceMap = createChoiceMap(applications, Application::getThirdChoiceUnivApplyInfoId); + + List firstChoiceApplicants = + createUniversityApplicantsResponses(univApplyInfos, firstChoiceMap, siteUser); + List secondChoiceApplicants = + createUniversityApplicantsResponses(univApplyInfos, secondChoiceMap, siteUser); + List thirdChoiceApplicants = + createUniversityApplicantsResponses(univApplyInfos, thirdChoiceMap, siteUser); + + return new ApplicationsResponse(firstChoiceApplicants, secondChoiceApplicants, thirdChoiceApplicants); } - private List getThirdChoiceApplicants(List universities, SiteUser siteUser, String term) { - return getApplicantsByChoice( - universities, - siteUser, - uia -> applicationRepository.findAllByThirdChoiceUniversityAndVerifyStatusAndTermAndIsDeleteFalse(uia, VerifyStatus.APPROVED, term) - ); + private Map> createChoiceMap( + List applications, + Function choiceIdExtractor) { + Map> choiceMap = new HashMap<>(); + + for (Application application : applications) { + Long choiceId = choiceIdExtractor.apply(application); + if (choiceId != null) { + choiceMap.computeIfAbsent(choiceId, k -> new ArrayList<>()).add(application); + } + } + + return choiceMap; } - private List getApplicantsByChoice( - List searchedUniversities, - SiteUser siteUser, - Function> findApplicationsByChoice) { - return universityInfoForApplyRepository.findByUniversitiesAndTerm(searchedUniversities, term).stream() - .map(universityInfoForApply -> UniversityApplicantsResponse.of( - universityInfoForApply, - findApplicationsByChoice.apply(universityInfoForApply).stream() - .map(ap -> ApplicantResponse.of( - ap, - Objects.equals(siteUser.getId(), ap.getSiteUser().getId()))) - .toList())) + private List createUniversityApplicantsResponses( + List univApplyInfos, + Map> choiceMap, + SiteUser siteUser) { + return univApplyInfos.stream() + .map(uia -> ApplicantsResponse.of(uia, choiceMap.getOrDefault(uia.getId(), List.of()), siteUser)) .toList(); } + + @Transactional(readOnly = true) + public void validateSiteUserCanViewApplicants(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserIdAndTerm(siteUser.getId(), term).getVerifyStatus(); + if (verifyStatus != VerifyStatus.APPROVED) { + throw new CustomException(APPLICATION_NOT_APPROVED); + } + } } diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java index 432e93aff..4fd403d3a 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -1,33 +1,31 @@ package com.example.solidconnection.application.service; +import static com.example.solidconnection.common.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + import com.example.solidconnection.application.domain.Application; import com.example.solidconnection.application.dto.ApplicationSubmissionResponse; import com.example.solidconnection.application.dto.ApplyRequest; -import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; import com.example.solidconnection.application.repository.ApplicationRepository; -import com.example.solidconnection.cache.annotation.DefaultCacheOut; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.GpaScoreRepository; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.VerifyStatus; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; -import static com.example.solidconnection.custom.exception.ErrorCode.GPA_SCORE_NOT_FOUND; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; - @RequiredArgsConstructor @Service public class ApplicationSubmissionService { @@ -35,9 +33,9 @@ public class ApplicationSubmissionService { public static final int APPLICATION_UPDATE_COUNT_LIMIT = 3; private final ApplicationRepository applicationRepository; - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; private final GpaScoreRepository gpaScoreRepository; private final LanguageTestScoreRepository languageTestScoreRepository; + private final SiteUserRepository siteUserRepository; @Value("${university.term}") private String term; @@ -45,26 +43,18 @@ public class ApplicationSubmissionService { // 학점 및 어학성적이 모두 유효한 경우에만 지원서 등록이 가능하다. // 기존에 있던 status field 우선 APRROVED로 입력시킨다. @Transactional - // todo: 임시로 새로운 신청 생성 시 기존 캐싱 데이터를 삭제한다. 추후 수정 필요 - @DefaultCacheOut( - key = {"applications:all"}, - cacheManager = "customCacheManager" - ) - public ApplicationSubmissionResponse apply(SiteUser siteUser, ApplyRequest applyRequest) { - UniversityChoiceRequest universityChoiceRequest = applyRequest.universityChoiceRequest(); + public ApplicationSubmissionResponse apply(long siteUserId, ApplyRequest applyRequest) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = applyRequest.univApplyInfoChoiceRequest(); GpaScore gpaScore = getValidGpaScore(siteUser, applyRequest.gpaScoreId()); LanguageTestScore languageTestScore = getValidLanguageTestScore(siteUser, applyRequest.languageTestScoreId()); - UniversityInfoForApply firstChoiceUniversity = universityInfoForApplyRepository - .getUniversityInfoForApplyByIdAndTerm(universityChoiceRequest.firstChoiceUniversityId(), term); - UniversityInfoForApply secondChoiceUniversity = Optional.ofNullable(universityChoiceRequest.secondChoiceUniversityId()) - .map(id -> universityInfoForApplyRepository.getUniversityInfoForApplyByIdAndTerm(id, term)) - .orElse(null); - UniversityInfoForApply thirdChoiceUniversity = Optional.ofNullable(universityChoiceRequest.thirdChoiceUniversityId()) - .map(id -> universityInfoForApplyRepository.getUniversityInfoForApplyByIdAndTerm(id, term)) - .orElse(null); + long firstChoiceUnivApplyInfoId = univApplyInfoChoiceRequest.firstChoiceUnivApplyInfoId(); + Long secondChoiceUnivApplyInfoId = univApplyInfoChoiceRequest.secondChoiceUnivApplyInfoId(); + Long thirdChoiceUnivApplyInfoId = univApplyInfoChoiceRequest.thirdChoiceUnivApplyInfoId(); - Optional existingApplication = applicationRepository.findBySiteUserAndTerm(siteUser, term); + Optional existingApplication = applicationRepository.findBySiteUserIdAndTerm(siteUser.getId(), term); int updateCount = existingApplication .map(application -> { validateUpdateLimitNotExceed(application); @@ -72,15 +62,27 @@ public ApplicationSubmissionResponse apply(SiteUser siteUser, ApplyRequest apply return application.getUpdateCount() + 1; }) .orElse(1); - Application newApplication = new Application(siteUser, gpaScore.getGpa(), languageTestScore.getLanguageTest(), - term, updateCount, firstChoiceUniversity, secondChoiceUniversity, thirdChoiceUniversity, getRandomNickname()); + + Application newApplication = new Application( + siteUser, + gpaScore.getGpa(), + languageTestScore.getLanguageTest(), + term, + updateCount, + firstChoiceUnivApplyInfoId, + secondChoiceUnivApplyInfoId, + thirdChoiceUnivApplyInfoId, + getRandomNickname() + ); + newApplication.setVerifyStatus(VerifyStatus.APPROVED); applicationRepository.save(newApplication); + return ApplicationSubmissionResponse.from(newApplication); } private GpaScore getValidGpaScore(SiteUser siteUser, Long gpaScoreId) { - GpaScore gpaScore = gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId) + GpaScore gpaScore = gpaScoreRepository.findGpaScoreBySiteUserIdAndId(siteUser.getId(), gpaScoreId) .orElseThrow(() -> new CustomException(GPA_SCORE_NOT_FOUND)); if (gpaScore.getVerifyStatus() != VerifyStatus.APPROVED) { throw new CustomException(INVALID_GPA_SCORE_STATUS); @@ -90,7 +92,7 @@ private GpaScore getValidGpaScore(SiteUser siteUser, Long gpaScoreId) { private LanguageTestScore getValidLanguageTestScore(SiteUser siteUser, Long languageTestScoreId) { LanguageTestScore languageTestScore = languageTestScoreRepository - .findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId) + .findLanguageTestScoreBySiteUserIdAndId(siteUser.getId(), languageTestScoreId) .orElseThrow(() -> new CustomException(INVALID_LANGUAGE_TEST_SCORE)); if (languageTestScore.getVerifyStatus() != VerifyStatus.APPROVED) { throw new CustomException(INVALID_LANGUAGE_TEST_SCORE_STATUS); diff --git a/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java b/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java index d9243ce39..c9699d4dc 100644 --- a/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java +++ b/src/main/java/com/example/solidconnection/application/service/NicknameCreator.java @@ -1,10 +1,9 @@ package com.example.solidconnection.application.service; -import lombok.NoArgsConstructor; - import java.util.List; import java.util.Random; import java.util.Set; +import lombok.NoArgsConstructor; @NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) class NicknameCreator { diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java index aef1309af..48009cc82 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -1,10 +1,18 @@ package com.example.solidconnection.auth.client; +import static com.example.solidconnection.common.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; + +import com.example.solidconnection.auth.client.config.AppleOAuthClientProperties; import com.example.solidconnection.auth.dto.oauth.AppleTokenDto; import com.example.solidconnection.auth.dto.oauth.AppleUserInfoDto; -import com.example.solidconnection.config.client.AppleOAuthClientProperties; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.oauth.OAuthClient; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; import io.jsonwebtoken.Jwts; +import java.security.PublicKey; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -16,32 +24,32 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; -import java.security.PublicKey; -import java.util.Objects; - -import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; - /* * 애플 인증을 위한 OAuth2 클라이언트 * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens * */ @Component @RequiredArgsConstructor -public class AppleOAuthClient { +public class AppleOAuthClient implements OAuthClient { private final RestTemplate restTemplate; private final AppleOAuthClientProperties properties; private final AppleOAuthClientSecretProvider clientSecretProvider; private final ApplePublicKeyProvider publicKeyProvider; - public AppleUserInfoDto processOAuth(String code) { + @Override + public AuthType getAuthType() { + return AuthType.APPLE; + } + + @Override + public OAuthUserInfoDto getUserInfo(String code) { String idToken = requestIdToken(code); PublicKey applePublicKey = publicKeyProvider.getApplePublicKey(idToken); return new AppleUserInfoDto(parseEmailFromToken(applePublicKey, idToken)); } - public String requestIdToken(String code) { + private String requestIdToken(String code) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap formData = buildFormData(code); diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java index 31228e5d3..a5a5cd315 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -1,22 +1,21 @@ package com.example.solidconnection.auth.client; -import com.example.solidconnection.config.client.AppleOAuthClientProperties; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; + +import com.example.solidconnection.auth.client.config.AppleOAuthClientProperties; +import com.example.solidconnection.common.exception.CustomException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.apache.tomcat.util.codec.binary.Base64; -import org.springframework.stereotype.Component; - import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Date; - -import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; +import lombok.RequiredArgsConstructor; +import org.apache.tomcat.util.codec.binary.Base64; +import org.springframework.stereotype.Component; /* * 애플 OAuth 에 필요한 클라이언트 시크릿은 매번 동적으로 생성해야 한다. diff --git a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java index 1cc708cc7..0b0aaa7d0 100644 --- a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java +++ b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java @@ -1,16 +1,16 @@ package com.example.solidconnection.auth.client; -import com.example.solidconnection.config.client.AppleOAuthClientProperties; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; +import static com.example.solidconnection.common.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; +import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; + +import com.example.solidconnection.auth.client.config.AppleOAuthClientProperties; +import com.example.solidconnection.common.exception.CustomException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.ExpiredJwtException; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; @@ -19,19 +19,18 @@ import java.util.Base64; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; - -import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; -import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; /* -* idToken 검증을 위해서 애플의 공개키를 가져온다. -* - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다. -* - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다. -* - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다. -* https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature -* */ + * idToken 검증을 위해서 애플의 공개키를 가져온다. + * - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다. + * - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다. + * - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다. + * https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature + * */ @Component @RequiredArgsConstructor public class ApplePublicKeyProvider { @@ -62,9 +61,9 @@ public PublicKey getApplePublicKey(String idToken) { } /* - * idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다 - * 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다. - * */ + * idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다 + * 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다. + * */ private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException { String[] jwtParts = idToken.split("\\."); if (jwtParts.length < 2) { diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 5d625cb7c..a25743f7d 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -1,9 +1,17 @@ package com.example.solidconnection.auth.client; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_OR_EXPIRED_KAKAO_AUTH_CODE; +import static com.example.solidconnection.common.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH; +import static com.example.solidconnection.common.exception.ErrorCode.KAKAO_USER_INFO_FAIL; + +import com.example.solidconnection.auth.client.config.KakaoOAuthClientProperties; import com.example.solidconnection.auth.dto.oauth.KakaoTokenDto; import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; -import com.example.solidconnection.config.client.KakaoOAuthClientProperties; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.oauth.OAuthClient; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -14,12 +22,6 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import java.util.Objects; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_OR_EXPIRED_KAKAO_AUTH_CODE; -import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH; -import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_USER_INFO_FAIL; - /* * 카카오 인증을 위한 OAuth2 클라이언트 * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code @@ -28,12 +30,18 @@ * */ @Component @RequiredArgsConstructor -public class KakaoOAuthClient { +public class KakaoOAuthClient implements OAuthClient { private final RestTemplate restTemplate; private final KakaoOAuthClientProperties kakaoOAuthClientProperties; - public KakaoUserInfoDto getUserInfo(String code) { + @Override + public AuthType getAuthType() { + return AuthType.KAKAO; + } + + @Override + public OAuthUserInfoDto getUserInfo(String code) { String kakaoAccessToken = getKakaoAccessToken(code); return getKakaoUserInfo(kakaoAccessToken); } diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/auth/client/config/AppleOAuthClientProperties.java similarity index 87% rename from src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java rename to src/main/java/com/example/solidconnection/auth/client/config/AppleOAuthClientProperties.java index c04908583..3ff927a03 100644 --- a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java +++ b/src/main/java/com/example/solidconnection/auth/client/config/AppleOAuthClientProperties.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.client; +package com.example.solidconnection.auth.client.config; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -13,4 +13,5 @@ public record AppleOAuthClientProperties( String keyId, String secretKey ) { + } diff --git a/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java b/src/main/java/com/example/solidconnection/auth/client/config/KakaoOAuthClientProperties.java similarity index 83% rename from src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java rename to src/main/java/com/example/solidconnection/auth/client/config/KakaoOAuthClientProperties.java index 73b196d76..36dcdaec1 100644 --- a/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java +++ b/src/main/java/com/example/solidconnection/auth/client/config/KakaoOAuthClientProperties.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.client; +package com.example.solidconnection.auth.client.config; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -9,4 +9,5 @@ public record KakaoOAuthClientProperties( String redirectUrl, String clientId ) { + } diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 9c84e8d22..f5a30bb2f 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -8,24 +8,23 @@ import com.example.solidconnection.auth.dto.SignUpRequest; import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; import com.example.solidconnection.auth.service.AuthService; -import com.example.solidconnection.auth.service.CommonSignUpTokenProvider; import com.example.solidconnection.auth.service.EmailSignInService; -import com.example.solidconnection.auth.service.EmailSignUpService; import com.example.solidconnection.auth.service.EmailSignUpTokenProvider; -import com.example.solidconnection.auth.service.oauth.AppleOAuthService; -import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; -import com.example.solidconnection.auth.service.oauth.OAuthSignUpService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.auth.service.SignUpService; +import com.example.solidconnection.auth.service.oauth.OAuthService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -37,35 +36,43 @@ public class AuthController { private final AuthService authService; - private final OAuthSignUpService oAuthSignUpService; - private final AppleOAuthService appleOAuthService; - private final KakaoOAuthService kakaoOAuthService; + private final OAuthService oAuthService; + private final SignUpService signUpService; private final EmailSignInService emailSignInService; - private final EmailSignUpService emailSignUpService; private final EmailSignUpTokenProvider emailSignUpTokenProvider; - private final CommonSignUpTokenProvider commonSignUpTokenProvider; + private final RefreshTokenCookieManager refreshTokenCookieManager; @PostMapping("/apple") public ResponseEntity processAppleOAuth( - @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest, + HttpServletResponse httpServletResponse ) { - OAuthResponse oAuthResponse = appleOAuthService.processOAuth(oAuthCodeRequest); + OAuthResponse oAuthResponse = oAuthService.processOAuth(AuthType.APPLE, oAuthCodeRequest); + if (oAuthResponse instanceof OAuthSignInResponse signInResponse) { + refreshTokenCookieManager.setCookie(httpServletResponse, signInResponse.refreshToken()); + } return ResponseEntity.ok(oAuthResponse); } @PostMapping("/kakao") public ResponseEntity processKakaoOAuth( - @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest, + HttpServletResponse httpServletResponse ) { - OAuthResponse oAuthResponse = kakaoOAuthService.processOAuth(oAuthCodeRequest); + OAuthResponse oAuthResponse = oAuthService.processOAuth(AuthType.KAKAO, oAuthCodeRequest); + if (oAuthResponse instanceof OAuthSignInResponse signInResponse) { + refreshTokenCookieManager.setCookie(httpServletResponse, signInResponse.refreshToken()); + } return ResponseEntity.ok(oAuthResponse); } @PostMapping("/email/sign-in") public ResponseEntity signInWithEmail( - @Valid @RequestBody EmailSignInRequest signInRequest + @Valid @RequestBody EmailSignInRequest signInRequest, + HttpServletResponse httpServletResponse ) { SignInResponse signInResponse = emailSignInService.signIn(signInRequest); + refreshTokenCookieManager.setCookie(httpServletResponse, signInResponse.refreshToken()); return ResponseEntity.ok(signInResponse); } @@ -74,8 +81,7 @@ public ResponseEntity signInWithEmail( public ResponseEntity signUpWithEmail( @Valid @RequestBody EmailSignUpTokenRequest signUpRequest ) { - emailSignUpService.validateUniqueEmail(signUpRequest.email()); - String signUpToken = emailSignUpTokenProvider.generateAndSaveSignUpToken(signUpRequest); + String signUpToken = emailSignUpTokenProvider.issueEmailSignUpToken(signUpRequest); return ResponseEntity.ok(new EmailSignUpTokenResponse(signUpToken)); } @@ -83,44 +89,46 @@ public ResponseEntity signUpWithEmail( public ResponseEntity signUp( @Valid @RequestBody SignUpRequest signUpRequest ) { - AuthType authType = commonSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); - if (AuthType.isEmail(authType)) { - SignInResponse signInResponse = emailSignUpService.signUp(signUpRequest); - return ResponseEntity.ok(signInResponse); - } - SignInResponse signInResponse = oAuthSignUpService.signUp(signUpRequest); + SignInResponse signInResponse = signUpService.signUp(signUpRequest); return ResponseEntity.ok(signInResponse); } @PostMapping("/sign-out") public ResponseEntity signOut( - Authentication authentication + Authentication authentication, + HttpServletResponse httpServletResponse ) { - String token = authentication.getCredentials().toString(); - if (token == null) { - throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다."); - } - authService.signOut(token); + String accessToken = getAccessToken(authentication); + authService.signOut(accessToken); + refreshTokenCookieManager.deleteCookie(httpServletResponse); return ResponseEntity.ok().build(); } - @PatchMapping("/quit") + @DeleteMapping("/quit") public ResponseEntity quit( - @AuthorizedUser SiteUser siteUser + @AuthorizedUser long siteUserId, + Authentication authentication, + HttpServletResponse httpServletResponse ) { - authService.quit(siteUser); + String accessToken = getAccessToken(authentication); + authService.quit(siteUserId, accessToken); + refreshTokenCookieManager.deleteCookie(httpServletResponse); return ResponseEntity.ok().build(); } @PostMapping("/reissue") public ResponseEntity reissueToken( - Authentication authentication + HttpServletRequest request ) { - String token = authentication.getCredentials().toString(); - if (token == null) { - throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다."); - } - ReissueResponse reissueResponse = authService.reissue(token); + String refreshToken = refreshTokenCookieManager.getRefreshToken(request); + ReissueResponse reissueResponse = authService.reissue(refreshToken); return ResponseEntity.ok(reissueResponse); } + + private String getAccessToken(Authentication authentication) { + if (authentication == null || !(authentication.getCredentials() instanceof String accessToken)) { + throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "엑세스 토큰이 없습니다."); + } + return accessToken; + } } diff --git a/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java b/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java new file mode 100644 index 000000000..7c6f4ec04 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java @@ -0,0 +1,75 @@ +package com.example.solidconnection.auth.controller; + +import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_NOT_EXISTS; + +import com.example.solidconnection.auth.controller.config.RefreshTokenCookieProperties; +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.common.exception.CustomException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.server.Cookie.SameSite; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RefreshTokenCookieManager { + + private static final String COOKIE_NAME = "refreshToken"; + private static final String PATH = "/"; + + private final RefreshTokenCookieProperties properties; + + public void setCookie(HttpServletResponse response, String refreshToken) { + long maxAge = convertExpireTimeToCookieMaxAge(TokenType.REFRESH.getExpireTime()); + setRefreshTokenCookie(response, refreshToken, maxAge); + } + + private long convertExpireTimeToCookieMaxAge(long milliSeconds) { + // jwt의 expireTime 단위인 millisecond를 cookie의 maxAge 단위인 second로 변환 + return milliSeconds / 1000; + } + + public void deleteCookie(HttpServletResponse response) { + setRefreshTokenCookie(response, "", 0); // 쿠키 삭제를 위해 maxAge를 0으로 설정 + } + + private void setRefreshTokenCookie( + HttpServletResponse response, String refreshToken, long maxAge + ) { + ResponseCookie cookie = ResponseCookie.from(COOKIE_NAME, refreshToken) + .httpOnly(true) + .secure(true) + .path(PATH) + .maxAge(maxAge) + .domain(properties.cookieDomain()) + .sameSite(SameSite.LAX.attributeValue()) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public String getRefreshToken(HttpServletRequest request) { + // 쿠키가 없거나 비어있는 경우 예외 발생 + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + throw new CustomException(REFRESH_TOKEN_NOT_EXISTS); + } + + // refreshToken 쿠키가 없는 경우 예외 발생 + Cookie refreshTokenCookie = Arrays.stream(cookies) + .filter(cookie -> COOKIE_NAME.equals(cookie.getName())) + .findFirst() + .orElseThrow(() -> new CustomException(REFRESH_TOKEN_NOT_EXISTS)); + + // 쿠키 값이 비어있는 경우 예외 발생 + String refreshToken = refreshTokenCookie.getValue(); + if (refreshToken == null || refreshToken.isBlank()) { + throw new CustomException(REFRESH_TOKEN_NOT_EXISTS); + } + return refreshToken; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/controller/config/RefreshTokenCookieProperties.java b/src/main/java/com/example/solidconnection/auth/controller/config/RefreshTokenCookieProperties.java new file mode 100644 index 000000000..ce6588f14 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/controller/config/RefreshTokenCookieProperties.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.controller.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "token.refresh") +public record RefreshTokenCookieProperties( + String cookieDomain +) { + +} diff --git a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java index caf1c7a9d..560b0e139 100644 --- a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java +++ b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java @@ -5,16 +5,16 @@ @Getter public enum TokenType { - ACCESS("ACCESS:", 1000 * 60 * 60), // 1hour - REFRESH("REFRESH:", 1000 * 60 * 60 * 24 * 7), // 7days + ACCESS("ACCESS:", 1000L * 60 * 60), // 1hour + REFRESH("REFRESH:", 1000L * 60 * 60 * 24 * 90), // 90days BLACKLIST("BLACKLIST:", ACCESS.expireTime), - SIGN_UP("SIGN_UP:", 1000 * 60 * 10), // 10min + SIGN_UP("SIGN_UP:", 1000L * 60 * 10), // 10min ; private final String prefix; - private final int expireTime; + private final long expireTime; - TokenType(String prefix, int expireTime) { + TokenType(String prefix, long expireTime) { this.prefix = prefix; this.expireTime = expireTime; } diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java index 306a8185a..3987bc508 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java @@ -1,13 +1,18 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.auth.dto.validation.Password; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record EmailSignInRequest( @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "유효한 이메일 주소를 입력해주세요.") String email, + @Password @NotBlank(message = "비밀번호를 입력해주세요.") String password ) { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java index 92073b434..ffafa222d 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java @@ -1,14 +1,18 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.auth.dto.validation.Password; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record EmailSignUpTokenRequest( - @Email(message = "이메일을 입력해주세요.") + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "유효한 이메일 주소를 입력해주세요.") String email, + @Password @NotBlank(message = "비밀번호를 입력해주세요.") String password ) { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java index c8e983d0c..d056da89b 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java @@ -3,4 +3,5 @@ public record EmailSignUpTokenResponse( String signUpToken ) { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java b/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java index 48b55e6cb..972470cca 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java @@ -1,5 +1,12 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.auth.service.AccessToken; + public record ReissueResponse( - String accessToken) { + String accessToken +) { + + public static ReissueResponse from(AccessToken accessToken) { + return new ReissueResponse(accessToken.token()); + } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java index a4ae442e2..b01fdd369 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -1,7 +1,14 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.auth.service.AccessToken; +import com.example.solidconnection.auth.service.RefreshToken; + public record SignInResponse( String accessToken, String refreshToken ) { + + public static SignInResponse of(AccessToken accessToken, RefreshToken refreshToken) { + return new SignInResponse(accessToken.token(), refreshToken.token()); + } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index 9bf92a295..bafb9b4c8 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -1,18 +1,21 @@ package com.example.solidconnection.auth.dto; import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.ExchangeStatus; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; - import java.util.List; public record SignUpRequest( String signUpToken, List interestedRegions, List interestedCountries, - PreparationStatus preparationStatus, + + @JsonProperty("preparationStatus") + ExchangeStatus exchangeStatus, + String profileImageUrl, @NotBlank(message = "닉네임을 입력해주세요.") @@ -23,7 +26,7 @@ public SiteUser toOAuthSiteUser(String email, AuthType authType) { email, this.nickname, this.profileImageUrl, - this.preparationStatus, + this.exchangeStatus, Role.MENTEE, authType ); @@ -34,7 +37,7 @@ public SiteUser toEmailSiteUser(String email, String encodedPassword) { email, this.nickname, this.profileImageUrl, - this.preparationStatus, + this.exchangeStatus, Role.MENTEE, AuthType.EMAIL, encodedPassword diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java index 6772cb2c2..019066c81 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java @@ -7,4 +7,5 @@ public record AppleTokenDto( @JsonProperty("id_token") String idToken ) { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java index 5c4363e51..5e95c2a5d 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java @@ -1,10 +1,10 @@ package com.example.solidconnection.auth.dto.oauth; /* -* 애플로부터 사용자의 정보를 받아올 때 사용한다. -* 카카오와 달리 애플은 더 엄격하게 사용자 정보를 관리하여, 이름이나 프로필 이미지 url 을 제공하지 않는다. -* 따라서 닉네임, 프로필 정보는 null 을 반환한다. -* */ + * 애플로부터 사용자의 정보를 받아올 때 사용한다. + * 카카오와 달리 애플은 더 엄격하게 사용자 정보를 관리하여, 이름이나 프로필 이미지 url 을 제공하지 않는다. + * 따라서 닉네임, 프로필 정보는 null 을 반환한다. + * */ public record AppleUserInfoDto(String email) implements OAuthUserInfoDto { @Override diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java index 6d4ccd10c..0663573b1 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java @@ -7,4 +7,5 @@ public record KakaoTokenDto( @JsonProperty("access_token") String accessToken, @JsonProperty("refresh_token") String refreshToken) { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java index abbdb7802..5ecdf7cca 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java @@ -6,4 +6,5 @@ public record OAuthCodeRequest( @NotBlank(message = "인증 코드를 입력해주세요.") String code) { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java index ddbe121f7..5fbb0fd0d 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java @@ -1,4 +1,5 @@ package com.example.solidconnection.auth.dto.oauth; public interface OAuthResponse { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java index 8ad429876..6ac121c46 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java @@ -4,4 +4,5 @@ public record OAuthSignInResponse( boolean isRegistered, String accessToken, String refreshToken) implements OAuthResponse { + } diff --git a/src/main/java/com/example/solidconnection/auth/dto/validation/Password.java b/src/main/java/com/example/solidconnection/auth/dto/validation/Password.java new file mode 100644 index 000000000..a896b5724 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/validation/Password.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.auth.dto.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordValidator.class) +public @interface Password { + + String message() default "비밀번호는 영문, 숫자, 특수문자를 포함한 8자리 이상이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/validation/PasswordValidator.java b/src/main/java/com/example/solidconnection/auth/dto/validation/PasswordValidator.java new file mode 100644 index 000000000..77f8da213 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/validation/PasswordValidator.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.auth.dto.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PasswordValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) { + return true; + } + + return value.matches("^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-={}\\[\\]|:;\"'<>,.?/`~])\\S{8,}$"); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/AccessToken.java b/src/main/java/com/example/solidconnection/auth/service/AccessToken.java new file mode 100644 index 000000000..3456a2171 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/AccessToken.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.siteuser.domain.Role; + +public record AccessToken( + Subject subject, + Role role, + String token +) { + + public AccessToken(String subject, Role role, String token) { + this(new Subject(subject), role, token); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthService.java b/src/main/java/com/example/solidconnection/auth/service/AuthService.java index 04bcadde7..01c162002 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthService.java @@ -1,56 +1,66 @@ package com.example.solidconnection.auth.service; +import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.auth.dto.ReissueResponse; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; - @RequiredArgsConstructor @Service public class AuthService { private final AuthTokenProvider authTokenProvider; + private final TokenBlackListService tokenBlackListService; + private final SiteUserRepository siteUserRepository; /* - * 로그아웃 한다. + * 로그아웃한다. * - 엑세스 토큰을 블랙리스트에 추가한다. + * - 리프레시 토큰을 삭제한다. * */ - public void signOut(String accessToken) { - authTokenProvider.generateAndSaveBlackListToken(accessToken); + public void signOut(String token) { + SiteUser siteUser = authTokenProvider.parseSiteUser(token); + AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.deleteRefreshTokenByAccessToken(accessToken); + tokenBlackListService.addToBlacklist(accessToken); } /* * 탈퇴한다. * - 탈퇴한 시점의 다음날을 탈퇴일로 잡는다. * - e.g. 2024-01-01 18:00 탈퇴 시, 2024-01-02 00:00 가 탈퇴일이 된다. + * - 로그아웃한다. * */ @Transactional - public void quit(SiteUser siteUser) { + public void quit(long siteUserId, String token) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); LocalDate tomorrow = LocalDate.now().plusDays(1); siteUser.setQuitedAt(tomorrow); + signOut(token); } /* * 액세스 토큰을 재발급한다. - * - 리프레시 토큰이 만료되었거나, 존재하지 않는다면 예외 응답을 반환한다. - * - 리프레시 토큰이 존재한다면, 액세스 토큰을 재발급한다. + * - 유효한 리프레시토큰이면, 액세스 토큰을 재발급한다. + * - 그렇지 않으면 예외를 발생시킨다. * */ - public ReissueResponse reissue(String subject) { - // 리프레시 토큰 만료 확인 - Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); - if (optionalRefreshToken.isEmpty()) { + public ReissueResponse reissue(String requestedRefreshToken) { + // 리프레시 토큰 확인 + if (!authTokenProvider.isValidRefreshToken(requestedRefreshToken)) { throw new CustomException(REFRESH_TOKEN_EXPIRED); } // 액세스 토큰 재발급 - String newAccessToken = authTokenProvider.generateAccessToken(subject); - return new ReissueResponse(newAccessToken); + SiteUser siteUser = authTokenProvider.parseSiteUser(requestedRefreshToken); + AccessToken newAccessToken = authTokenProvider.generateAccessToken(siteUser); + return ReissueResponse.from(newAccessToken); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java index da040a8d5..8e55f77d4 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java @@ -1,53 +1,72 @@ package com.example.solidconnection.auth.service; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; -import java.util.Optional; - -import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; - @Component -public class AuthTokenProvider extends TokenProvider { +@RequiredArgsConstructor +public class AuthTokenProvider { - public AuthTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { - super(jwtProperties, redisTemplate); - } + private static final String ROLE_CLAIM_KEY = "role"; - public String generateAccessToken(SiteUser siteUser) { - String subject = siteUser.getId().toString(); - return generateToken(subject, TokenType.ACCESS); - } + private final RedisTemplate redisTemplate; + private final TokenProvider tokenProvider; + private final SiteUserRepository siteUserRepository; - public String generateAccessToken(String subject) { - return generateToken(subject, TokenType.ACCESS); + public AccessToken generateAccessToken(SiteUser siteUser) { + Subject subject = toSubject(siteUser); + Role role = siteUser.getRole(); + String token = tokenProvider.generateToken( + subject.value(), + Map.of(ROLE_CLAIM_KEY, role.name()), + TokenType.ACCESS + ); + return new AccessToken(subject, role, token); } - public String generateAndSaveRefreshToken(SiteUser siteUser) { - String subject = siteUser.getId().toString(); - String refreshToken = generateToken(subject, TokenType.REFRESH); - return saveToken(refreshToken, TokenType.REFRESH); + public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) { + Subject subject = toSubject(siteUser); + String token = tokenProvider.generateToken(subject.value(), TokenType.REFRESH); + tokenProvider.saveToken(token, TokenType.REFRESH); + return new RefreshToken(subject, token); } - public String generateAndSaveBlackListToken(String accessToken) { - String blackListToken = generateToken(accessToken, TokenType.BLACKLIST); - return saveToken(blackListToken, TokenType.BLACKLIST); + /* + * 유효한 리프레시 토큰인지 확인한다. + * - 요청된 토큰과 같은 subject 의 리프레시 토큰을 조회한다. + * - 조회된 리프레시 토큰과 요청된 토큰이 같은지 비교한다. + * */ + public boolean isValidRefreshToken(String requestedRefreshToken) { + String subject = tokenProvider.parseSubject(requestedRefreshToken); + String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); + String foundRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); + return Objects.equals(requestedRefreshToken, foundRefreshToken); } - public Optional findRefreshToken(String subject) { + public void deleteRefreshTokenByAccessToken(AccessToken accessToken) { + String subject = accessToken.subject().value(); String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); - return Optional.ofNullable(redisTemplate.opsForValue().get(refreshTokenKey)); + redisTemplate.delete(refreshTokenKey); } - public Optional findBlackListToken(String subject) { - String blackListTokenKey = TokenType.BLACKLIST.addPrefix(subject); - return Optional.ofNullable(redisTemplate.opsForValue().get(blackListTokenKey)); + public SiteUser parseSiteUser(String token) { + String subject = tokenProvider.parseSubject(token); + long siteUserId = Long.parseLong(subject); + return siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); } - public String getEmail(String token) { - return parseSubjectIgnoringExpiration(token, jwtProperties.secret()); + public Subject toSubject(SiteUser siteUser) { + return new Subject(siteUser.getId().toString()); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java deleted file mode 100644 index 3d0eda53b..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.util.JwtUtils; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import static com.example.solidconnection.auth.service.EmailSignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; - -@Component -@RequiredArgsConstructor -public class CommonSignUpTokenProvider { - - private final JwtProperties jwtProperties; - - public AuthType parseAuthType(String signUpToken) { - try { - String authTypeStr = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()).get(AUTH_TYPE_CLAIM_KEY, String.class); - return AuthType.valueOf(authTypeStr); - } catch (Exception e) { - throw new CustomException(SIGN_UP_TOKEN_INVALID); - } - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java index 3e26309a5..4dac56586 100644 --- a/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java @@ -1,22 +1,18 @@ package com.example.solidconnection.auth.service; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_IN_FAILED; + import com.example.solidconnection.auth.dto.EmailSignInRequest; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - -/* - * 보안을 위해 이메일과 비밀번호 중 무엇이 틀렸는지 구체적으로 응답하지 않는다. - * */ @Service @RequiredArgsConstructor public class EmailSignInService { @@ -25,19 +21,21 @@ public class EmailSignInService { private final SiteUserRepository siteUserRepository; private final PasswordEncoder passwordEncoder; + @Transactional(readOnly = true) public SignInResponse signIn(EmailSignInRequest signInRequest) { - Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(signInRequest.email(), AuthType.EMAIL); - if (optionalSiteUser.isPresent()) { - SiteUser siteUser = optionalSiteUser.get(); - validatePassword(signInRequest.password(), siteUser.getPassword()); - return signInService.signIn(siteUser); - } - throw new CustomException(USER_NOT_FOUND, "이메일과 비밀번호를 확인해주세요."); + SiteUser siteUser = getEmailMatchingUserOrThrow(signInRequest.email()); + validatePassword(signInRequest.password(), siteUser.getPassword()); + return signInService.signIn(siteUser); + } + + private SiteUser getEmailMatchingUserOrThrow(String email) { + return siteUserRepository.findByEmailAndAuthType(email, AuthType.EMAIL) + .orElseThrow(() -> new CustomException(SIGN_IN_FAILED)); } private void validatePassword(String rawPassword, String encodedPassword) { if (!passwordEncoder.matches(rawPassword, encodedPassword)) { - throw new CustomException(USER_NOT_FOUND, "이메일과 비밀번호를 확인해주세요."); + throw new CustomException(SIGN_IN_FAILED); } } } diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java deleted file mode 100644 index 37f6681ea..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.repositories.CountryRepository; -import com.example.solidconnection.repositories.InterestedCountyRepository; -import com.example.solidconnection.repositories.InterestedRegionRepository; -import com.example.solidconnection.repositories.RegionRepository; -import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import org.springframework.stereotype.Service; - -import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; - -@Service -public class EmailSignUpService extends SignUpService { - - private final EmailSignUpTokenProvider emailSignUpTokenProvider; - - public EmailSignUpService(SignInService signInService, SiteUserRepository siteUserRepository, - RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, - CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository, - EmailSignUpTokenProvider emailSignUpTokenProvider) { - super(signInService, siteUserRepository, regionRepository, interestedRegionRepository, countryRepository, interestedCountyRepository); - this.emailSignUpTokenProvider = emailSignUpTokenProvider; - } - - public void validateUniqueEmail(String email) { - if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { - throw new CustomException(USER_ALREADY_EXISTED); - } - } - - @Override - protected void validateSignUpToken(SignUpRequest signUpRequest) { - emailSignUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); - } - - @Override - protected void validateUserNotDuplicated(SignUpRequest signUpRequest) { - String email = emailSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); - if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { - throw new CustomException(USER_ALREADY_EXISTED); - } - } - - @Override - protected SiteUser createSiteUser(SignUpRequest signUpRequest) { - String email = emailSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); - String encodedPassword = emailSignUpTokenProvider.parseEncodedPassword(signUpRequest.signUpToken()); - return signUpRequest.toEmailSiteUser(email, encodedPassword); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java index 1c27a87bd..a3e2e5dc9 100644 --- a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java @@ -1,92 +1,32 @@ package com.example.solidconnection.auth.service; -import com.example.solidconnection.auth.domain.TokenType; import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.siteuser.domain.AuthType; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; - -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; -import static com.example.solidconnection.util.JwtUtils.parseClaims; -import static com.example.solidconnection.util.JwtUtils.parseSubject; +import org.springframework.transaction.annotation.Transactional; @Component -public class EmailSignUpTokenProvider extends TokenProvider { - - static final String PASSWORD_CLAIM_KEY = "password"; - static final String AUTH_TYPE_CLAIM_KEY = "authType"; - - private final PasswordEncoder passwordEncoder; +@RequiredArgsConstructor +public class EmailSignUpTokenProvider { - public EmailSignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate, - PasswordEncoder passwordEncoder) { - super(jwtProperties, redisTemplate); - this.passwordEncoder = passwordEncoder; - } + private final SignUpTokenProvider signUpTokenProvider; + private final SiteUserRepository siteUserRepository; + private final PasswordTemporaryStorage passwordTemporaryStorage; - public String generateAndSaveSignUpToken(EmailSignUpTokenRequest request) { + @Transactional(readOnly = true) + public String issueEmailSignUpToken(EmailSignUpTokenRequest request) { String email = request.email(); String password = request.password(); - String encodedPassword = passwordEncoder.encode(password); - Map emailSignUpClaims = new HashMap<>(Map.of( - PASSWORD_CLAIM_KEY, encodedPassword, - AUTH_TYPE_CLAIM_KEY, AuthType.EMAIL - )); - Claims claims = Jwts.claims(emailSignUpClaims).setSubject(email); - Date now = new Date(); - Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); - - String signUpToken = Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(expiredDate) - .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) - .compact(); - return saveToken(signUpToken, TokenType.SIGN_UP); - } - public void validateSignUpToken(String token) { - validateFormatAndExpiration(token); - String email = parseEmail(token); - validateIssuedByServer(email); - } - - private void validateFormatAndExpiration(String token) { - try { - Claims claims = parseClaims(token, jwtProperties.secret()); - Objects.requireNonNull(claims.getSubject()); - String encodedPassword = claims.get(PASSWORD_CLAIM_KEY, String.class); - Objects.requireNonNull(encodedPassword); - } catch (Exception e) { - throw new CustomException(SIGN_UP_TOKEN_INVALID); - } - } - - private void validateIssuedByServer(String email) { - String key = TokenType.SIGN_UP.addPrefix(email); - if (redisTemplate.opsForValue().get(key) == null) { - throw new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { + throw new CustomException(ErrorCode.USER_ALREADY_EXISTED); } - } - - public String parseEmail(String token) { - return parseSubject(token, jwtProperties.secret()); - } - public String parseEncodedPassword(String token) { - Claims claims = parseClaims(token, jwtProperties.secret()); - return claims.get(PASSWORD_CLAIM_KEY, String.class); + passwordTemporaryStorage.save(email, password); + return signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.EMAIL); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/PasswordTemporaryStorage.java b/src/main/java/com/example/solidconnection/auth/service/PasswordTemporaryStorage.java new file mode 100644 index 000000000..adcb8bf68 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/PasswordTemporaryStorage.java @@ -0,0 +1,46 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PasswordTemporaryStorage { + + private static final String KEY_PREFIX = "password:"; + + private final RedisTemplate redisTemplate; + private final PasswordEncoder passwordEncoder; + + public void save(String email, String rawPassword) { + String encodedPassword = passwordEncoder.encode(rawPassword); + redisTemplate.opsForValue().set( + convertToKey(email), + encodedPassword, + TokenType.SIGN_UP.getExpireTime(), + TimeUnit.MILLISECONDS + ); + } + + public Optional findByEmail(String email) { + String encodedPassword = redisTemplate.opsForValue().get(convertToKey(email)); + if (encodedPassword == null) { + return Optional.empty(); + } + return Optional.of(encodedPassword); + } + + public void deleteByEmail(String email) { + String key = convertToKey(email); + redisTemplate.delete(key); + } + + private String convertToKey(String email) { + return KEY_PREFIX + email; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/RefreshToken.java b/src/main/java/com/example/solidconnection/auth/service/RefreshToken.java new file mode 100644 index 000000000..2aac3ad8c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/RefreshToken.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.auth.service; + +public record RefreshToken( + Subject subject, + String token +) { + + RefreshToken(String subject, String token) { + this(new Subject(subject), token); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/SignInService.java index 820d2e573..16ec4c484 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignInService.java @@ -15,9 +15,9 @@ public class SignInService { @Transactional public SignInResponse signIn(SiteUser siteUser) { resetQuitedAt(siteUser); - String accessToken = authTokenProvider.generateAccessToken(siteUser); - String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); - return new SignInResponse(accessToken, refreshToken); + AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); + RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + return SignInResponse.of(accessToken, refreshToken); } private void resetQuitedAt(SiteUser siteUser) { diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java index 319083658..d6feed9e1 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java @@ -1,22 +1,22 @@ package com.example.solidconnection.auth.service; +import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.common.exception.ErrorCode.USER_ALREADY_EXISTED; + import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.entity.InterestedCountry; -import com.example.solidconnection.entity.InterestedRegion; -import com.example.solidconnection.repositories.CountryRepository; -import com.example.solidconnection.repositories.InterestedCountyRepository; -import com.example.solidconnection.repositories.InterestedRegionRepository; -import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.location.country.service.InterestedCountryService; +import com.example.solidconnection.location.region.service.InterestedRegionService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; - /* * 우리 서버에서 인증되었음을 확인하기 위한 signUpToken 을 검증한다. * - 사용자 정보를 DB에 저장한다. @@ -24,67 +24,77 @@ * - 관심 국가와 지역은 site_user_id를 참조하므로, 사용자 저장 후 저장한다. * - 바로 로그인하도록 액세스 토큰과 리프레시 토큰을 발급한다. * */ -public abstract class SignUpService { +@Service +@RequiredArgsConstructor +public class SignUpService { - protected final SignInService signInService; - protected final SiteUserRepository siteUserRepository; - protected final RegionRepository regionRepository; - protected final InterestedRegionRepository interestedRegionRepository; - protected final CountryRepository countryRepository; - protected final InterestedCountyRepository interestedCountyRepository; - - protected SignUpService(SignInService signInService, SiteUserRepository siteUserRepository, - RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, - CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository) { - this.signInService = signInService; - this.siteUserRepository = siteUserRepository; - this.regionRepository = regionRepository; - this.interestedRegionRepository = interestedRegionRepository; - this.countryRepository = countryRepository; - this.interestedCountyRepository = interestedCountyRepository; - } + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + private final InterestedRegionService interestedRegionService; + private final InterestedCountryService interestedCountryService; + private final SignUpTokenProvider signUpTokenProvider; + private final PasswordTemporaryStorage passwordTemporaryStorage; @Transactional public SignInResponse signUp(SignUpRequest signUpRequest) { // 검증 - validateSignUpToken(signUpRequest); - validateUserNotDuplicated(signUpRequest); - validateNicknameDuplicated(signUpRequest.nickname()); + signUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); + String email = signUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = signUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + validateNicknameNotDuplicated(signUpRequest.nickname()); + validateUserNotDuplicated(email, authType); + + // 임시 저장된 비밀번호 가져오기 + String password = getTemporarySavedPassword(email, authType); // 사용자 저장 - SiteUser siteUser = siteUserRepository.save(createSiteUser(signUpRequest)); + SiteUser siteUser = siteUserRepository.save(new SiteUser( + email, + signUpRequest.nickname(), + signUpRequest.profileImageUrl(), + signUpRequest.exchangeStatus(), + Role.MENTEE, + authType, + password + )); // 관심 지역, 국가 저장 - saveInterestedRegion(signUpRequest, siteUser); - saveInterestedCountry(signUpRequest, siteUser); + interestedRegionService.saveInterestedRegion(siteUser, signUpRequest.interestedRegions()); + interestedCountryService.saveInterestedCountry(siteUser, signUpRequest.interestedCountries()); // 로그인 - return signInService.signIn(siteUser); + SignInResponse response = signInService.signIn(siteUser); + + // 회원가입을 위해 저장한 데이터(SignUpToken, 비밀번호) 삭제 + clearSignUpData(email, authType); + + return response; } - private void validateNicknameDuplicated(String nickname) { + private void validateNicknameNotDuplicated(String nickname) { if (siteUserRepository.existsByNickname(nickname)) { throw new CustomException(NICKNAME_ALREADY_EXISTED); } } - private void saveInterestedRegion(SignUpRequest signUpRequest, SiteUser savedSiteUser) { - List interestedRegionNames = signUpRequest.interestedRegions(); - List interestedRegions = regionRepository.findByKoreanNames(interestedRegionNames).stream() - .map(region -> new InterestedRegion(savedSiteUser, region)) - .toList(); - interestedRegionRepository.saveAll(interestedRegions); + private void validateUserNotDuplicated(String email, AuthType authType) { + if (siteUserRepository.existsByEmailAndAuthType(email, authType)) { + throw new CustomException(USER_ALREADY_EXISTED); + } } - private void saveInterestedCountry(SignUpRequest signUpRequest, SiteUser savedSiteUser) { - List interestedCountryNames = signUpRequest.interestedCountries(); - List interestedCountries = countryRepository.findByKoreanNames(interestedCountryNames).stream() - .map(country -> new InterestedCountry(savedSiteUser, country)) - .toList(); - interestedCountyRepository.saveAll(interestedCountries); + private String getTemporarySavedPassword(String email, AuthType authType) { + if (authType == AuthType.EMAIL) { + return passwordTemporaryStorage.findByEmail(email) + .orElseThrow(() -> new CustomException(SIGN_UP_TOKEN_INVALID)); + } + return null; } - protected abstract void validateSignUpToken(SignUpRequest signUpRequest); - protected abstract void validateUserNotDuplicated(SignUpRequest signUpRequest); - protected abstract SiteUser createSiteUser(SignUpRequest signUpRequest); + private void clearSignUpData(String email, AuthType authType) { + if (authType == AuthType.EMAIL) { + passwordTemporaryStorage.deleteByEmail(email); + } + signUpTokenProvider.deleteByEmail(email); + } } diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java similarity index 65% rename from src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java rename to src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java index c3a95dbe9..05480b10d 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java @@ -1,34 +1,32 @@ -package com.example.solidconnection.auth.service.oauth; +package com.example.solidconnection.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.auth.service.TokenProvider; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.AuthType; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Objects; - -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; -import static com.example.solidconnection.util.JwtUtils.parseClaims; -import static com.example.solidconnection.util.JwtUtils.parseSubject; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; @Component -public class OAuthSignUpTokenProvider extends TokenProvider { +@RequiredArgsConstructor +public class SignUpTokenProvider { - static final String AUTH_TYPE_CLAIM_KEY = "authType"; + private static final String AUTH_TYPE_CLAIM_KEY = "authType"; - public OAuthSignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { - super(jwtProperties, redisTemplate); - } + private final JwtProperties jwtProperties; + private final RedisTemplate redisTemplate; + private final TokenProvider tokenProvider; public String generateAndSaveSignUpToken(String email, AuthType authType) { Map authTypeClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, authType)); @@ -42,7 +40,12 @@ public String generateAndSaveSignUpToken(String email, AuthType authType) { .setExpiration(expiredDate) .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) .compact(); - return saveToken(signUpToken, TokenType.SIGN_UP); + return tokenProvider.saveToken(signUpToken, TokenType.SIGN_UP); + } + + public void deleteByEmail(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + redisTemplate.delete(key); } public void validateSignUpToken(String token) { @@ -51,9 +54,9 @@ public void validateSignUpToken(String token) { validateIssuedByServer(email); } - private void validateFormatAndExpiration(String token) { + private void validateFormatAndExpiration(String token) { // 파싱되는지, AuthType이 포함되어있는지 검증 try { - Claims claims = parseClaims(token, jwtProperties.secret()); + Claims claims = tokenProvider.parseClaims(token); Objects.requireNonNull(claims.getSubject()); String serializedAuthType = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); AuthType.valueOf(serializedAuthType); @@ -70,11 +73,11 @@ private void validateIssuedByServer(String email) { } public String parseEmail(String token) { - return parseSubject(token, jwtProperties.secret()); + return tokenProvider.parseSubject(token); } public AuthType parseAuthType(String token) { - Claims claims = parseClaims(token, jwtProperties.secret()); + Claims claims = tokenProvider.parseClaims(token); String authTypeStr = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); return AuthType.valueOf(authTypeStr); } diff --git a/src/main/java/com/example/solidconnection/auth/service/Subject.java b/src/main/java/com/example/solidconnection/auth/service/Subject.java new file mode 100644 index 000000000..15e5c6c75 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/Subject.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.service; + +public record Subject( + String value +) { + +} diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java index f5f638ab3..22120b084 100644 --- a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java @@ -1,47 +1,18 @@ package com.example.solidconnection.auth.service; import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.config.security.JwtProperties; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.springframework.data.redis.core.RedisTemplate; +import java.util.Map; -import java.util.Date; -import java.util.concurrent.TimeUnit; +public interface TokenProvider { -import static com.example.solidconnection.util.JwtUtils.parseSubject; + String generateToken(String string, TokenType tokenType); -public abstract class TokenProvider { + String generateToken(String string, Map claims, TokenType tokenType); - protected final JwtProperties jwtProperties; - protected final RedisTemplate redisTemplate; + String saveToken(String token, TokenType tokenType); - public TokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { - this.jwtProperties = jwtProperties; - this.redisTemplate = redisTemplate; - } + String parseSubject(String token); - protected final String generateToken(String string, TokenType tokenType) { - Claims claims = Jwts.claims().setSubject(string); - Date now = new Date(); - Date expiredDate = new Date(now.getTime() + tokenType.getExpireTime()); - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(expiredDate) - .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) - .compact(); - } - - protected final String saveToken(String token, TokenType tokenType) { - String subject = parseSubject(token, jwtProperties.secret()); - redisTemplate.opsForValue().set( - tokenType.addPrefix(subject), - token, - tokenType.getExpireTime(), - TimeUnit.MILLISECONDS - ); - return token; - } + Claims parseClaims(String token); } diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java deleted file mode 100644 index 2605ad89f..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example.solidconnection.auth.service.oauth; - -import com.example.solidconnection.auth.client.AppleOAuthClient; -import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; -import com.example.solidconnection.auth.service.SignInService; -import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import org.springframework.stereotype.Service; - -@Service -public class AppleOAuthService extends OAuthService { - - private final AppleOAuthClient appleOAuthClient; - - public AppleOAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, - AppleOAuthClient appleOAuthClient, SignInService signInService) { - super(OAuthSignUpTokenProvider, siteUserRepository, signInService); - this.appleOAuthClient = appleOAuthClient; - } - - @Override - protected OAuthUserInfoDto getOAuthUserInfo(String code) { - return appleOAuthClient.processOAuth(code); - } - - @Override - protected AuthType getAuthType() { - return AuthType.APPLE; - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java deleted file mode 100644 index c2202ab2a..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example.solidconnection.auth.service.oauth; - -import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; -import com.example.solidconnection.auth.service.SignInService; -import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import org.springframework.stereotype.Service; - -@Service -public class KakaoOAuthService extends OAuthService { - - private final KakaoOAuthClient kakaoOAuthClient; - - public KakaoOAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, - KakaoOAuthClient kakaoOAuthClient, SignInService signInService) { - super(OAuthSignUpTokenProvider, siteUserRepository, signInService); - this.kakaoOAuthClient = kakaoOAuthClient; - } - - @Override - protected OAuthUserInfoDto getOAuthUserInfo(String code) { - return kakaoOAuthClient.getUserInfo(code); - } - - @Override - protected AuthType getAuthType() { - return AuthType.KAKAO; - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthClient.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthClient.java new file mode 100644 index 000000000..a62872f0c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthClient.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.siteuser.domain.AuthType; + +public interface OAuthClient { + + OAuthUserInfoDto getUserInfo(String code); + + AuthType getAuthType(); +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthClientMap.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthClientMap.java new file mode 100644 index 000000000..45e510136 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthClientMap.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.siteuser.domain.AuthType; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class OAuthClientMap { + + private final Map oauthClientMap; + + public OAuthClientMap(List oAuthClientList) { + this.oauthClientMap = oAuthClientList.stream() + .collect(Collectors.toMap(OAuthClient::getAuthType, Function.identity())); + } + + public OAuthClient getOAuthClient(AuthType authType) { + OAuthClient oauthClient = oauthClientMap.get(authType); + if (oauthClient == null) { + throw new CustomException( + ErrorCode.NOT_DEFINED_ERROR, + "처리할 수 있는 OAuthClient가 없습니다. authType: " + authType.name() + ); + } + return oauthClient; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java index 6e9bf7030..9343bfa21 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -1,6 +1,5 @@ package com.example.solidconnection.auth.service.oauth; - import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; import com.example.solidconnection.auth.dto.oauth.OAuthResponse; @@ -8,54 +7,50 @@ import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.auth.service.SignUpTokenProvider; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import org.springframework.transaction.annotation.Transactional; - import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /* * OAuth 제공자로부터 이메일을 받아 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. * 기존 회원 : 로그인한다. * 신규 회원 : 회원가입할 때 필요한 정보를 제공한다. * */ -public abstract class OAuthService { +@Service +@RequiredArgsConstructor +public class OAuthService { - private final OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + private final SignUpTokenProvider signUpTokenProvider; private final SignInService signInService; private final SiteUserRepository siteUserRepository; - - protected OAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, SignInService signInService) { - this.OAuthSignUpTokenProvider = OAuthSignUpTokenProvider; - this.siteUserRepository = siteUserRepository; - this.signInService = signInService; - } + private final OAuthClientMap oauthClientMap; @Transactional - public OAuthResponse processOAuth(OAuthCodeRequest oauthCodeRequest) { - OAuthUserInfoDto userInfoDto = getOAuthUserInfo(oauthCodeRequest.code()); - String email = userInfoDto.getEmail(); - Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, getAuthType()); + public OAuthResponse processOAuth(AuthType authType, OAuthCodeRequest codeRequest) { + OAuthClient oauthClient = oauthClientMap.getOAuthClient(authType); + OAuthUserInfoDto userInfo = oauthClient.getUserInfo(codeRequest.code()); + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(userInfo.getEmail(), authType); if (optionalSiteUser.isPresent()) { SiteUser siteUser = optionalSiteUser.get(); return getSignInResponse(siteUser); } - return getSignUpPrepareResponse(userInfoDto); + return getSignUpPrepareResponse(userInfo, authType); } - protected final OAuthSignInResponse getSignInResponse(SiteUser siteUser) { + private OAuthSignInResponse getSignInResponse(SiteUser siteUser) { SignInResponse signInResponse = signInService.signIn(siteUser); return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); } - protected final SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { - String signUpToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), getAuthType()); + private SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto, AuthType authType) { + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), authType); return SignUpPrepareResponse.of(userInfoDto, signUpToken); } - - protected abstract OAuthUserInfoDto getOAuthUserInfo(String code); - protected abstract AuthType getAuthType(); } diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java deleted file mode 100644 index a46728bb2..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.example.solidconnection.auth.service.oauth; - -import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.service.SignInService; -import com.example.solidconnection.auth.service.SignUpService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.repositories.CountryRepository; -import com.example.solidconnection.repositories.InterestedCountyRepository; -import com.example.solidconnection.repositories.InterestedRegionRepository; -import com.example.solidconnection.repositories.RegionRepository; -import com.example.solidconnection.siteuser.domain.AuthType; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import org.springframework.stereotype.Service; - -import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; - -@Service -public class OAuthSignUpService extends SignUpService { - - private final OAuthSignUpTokenProvider oAuthSignUpTokenProvider; - - OAuthSignUpService(SignInService signInService, SiteUserRepository siteUserRepository, - RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, - CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository, - OAuthSignUpTokenProvider oAuthSignUpTokenProvider) { - super(signInService, siteUserRepository, regionRepository, interestedRegionRepository, countryRepository, interestedCountyRepository); - this.oAuthSignUpTokenProvider = oAuthSignUpTokenProvider; - } - - @Override - protected void validateSignUpToken(SignUpRequest signUpRequest) { - oAuthSignUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); - } - - @Override - protected void validateUserNotDuplicated(SignUpRequest signUpRequest) { - String email = oAuthSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); - AuthType authType = oAuthSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); - if (siteUserRepository.existsByEmailAndAuthType(email, authType)) { - throw new CustomException(USER_ALREADY_EXISTED); - } - } - - @Override - protected SiteUser createSiteUser(SignUpRequest signUpRequest) { - String email = oAuthSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); - AuthType authType = oAuthSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); - return signUpRequest.toOAuthSiteUser(email, authType); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java b/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java new file mode 100644 index 000000000..d7c968ccf --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java @@ -0,0 +1,77 @@ +package com.example.solidconnection.auth.token; + +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_TOKEN; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements TokenProvider { + + private final JwtProperties jwtProperties; + private final RedisTemplate redisTemplate; + + @Override + public final String generateToken(String string, TokenType tokenType) { + return generateJwtTokenValue(string, Map.of(), tokenType.getExpireTime()); + } + + @Override + public String generateToken(String string, Map customClaims, TokenType tokenType) { + return generateJwtTokenValue(string, customClaims, tokenType.getExpireTime()); + } + + private String generateJwtTokenValue(String subject, Map claims, long expireTime) { + Claims jwtClaims = Jwts.claims().setSubject(subject); + jwtClaims.putAll(claims); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + expireTime); + return Jwts.builder() + .setClaims(jwtClaims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + } + + @Override + public final String saveToken(String token, TokenType tokenType) { + String subject = parseSubject(token); + redisTemplate.opsForValue().set( + tokenType.addPrefix(subject), + token, + tokenType.getExpireTime(), + TimeUnit.MILLISECONDS + ); + return token; + } + + @Override + public String parseSubject(String token) { + return parseClaims(token).getSubject(); + } + + @Override + public Claims parseClaims(String token) { + try { + return Jwts.parser() + .setSigningKey(jwtProperties.secret()) + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + throw new CustomException(INVALID_TOKEN); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java b/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java new file mode 100644 index 000000000..7f208710c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.auth.token; + +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; + +import com.example.solidconnection.auth.service.AccessToken; +import com.example.solidconnection.security.filter.BlacklistChecker; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TokenBlackListService implements BlacklistChecker { + + private static final String SIGN_OUT_VALUE = "signOut"; + + private final RedisTemplate redisTemplate; + + /* + * 액세스 토큰을 블랙리스트에 저장한다. + * - key = BLACKLIST:{accessToken} + * - value = {SIGN_OUT_VALUE} -> key 의 존재만 확인하므로, value 에는 무슨 값이 들어가도 상관없다. + * */ + public void addToBlacklist(AccessToken accessToken) { + String blackListKey = BLACKLIST.addPrefix(accessToken.token()); + redisTemplate.opsForValue().set(blackListKey, SIGN_OUT_VALUE); + } + + @Override + public boolean isTokenBlacklisted(String accessToken) { + String blackListTokenKey = BLACKLIST.addPrefix(accessToken); + return redisTemplate.hasKey(blackListTokenKey); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/JwtProperties.java b/src/main/java/com/example/solidconnection/auth/token/config/JwtProperties.java similarity index 74% rename from src/main/java/com/example/solidconnection/config/security/JwtProperties.java rename to src/main/java/com/example/solidconnection/auth/token/config/JwtProperties.java index e0c63da46..2d81601ee 100644 --- a/src/main/java/com/example/solidconnection/config/security/JwtProperties.java +++ b/src/main/java/com/example/solidconnection/auth/token/config/JwtProperties.java @@ -1,7 +1,8 @@ -package com.example.solidconnection.config.security; +package com.example.solidconnection.auth.token.config; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "jwt") public record JwtProperties(String secret) { + } diff --git a/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java b/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java index c785168b3..0d2ee9eaf 100644 --- a/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java +++ b/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java @@ -1,13 +1,12 @@ package com.example.solidconnection.cache; +import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; import org.springframework.stereotype.Component; -import java.nio.charset.StandardCharsets; - @Component @RequiredArgsConstructor @Slf4j diff --git a/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java b/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java index 48c36b28c..f2c08edcc 100644 --- a/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java +++ b/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java @@ -1,10 +1,9 @@ package com.example.solidconnection.cache; -import org.springframework.stereotype.Component; - import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; @Component public class CompletableFutureManager { diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java index a37e80f51..b6a9fe0b0 100644 --- a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -1,8 +1,20 @@ package com.example.solidconnection.cache; +import static com.example.solidconnection.community.post.service.RedisConstants.CREATE_CHANNEL; +import static com.example.solidconnection.community.post.service.RedisConstants.LOCK_TIMEOUT_MS; +import static com.example.solidconnection.community.post.service.RedisConstants.MAX_WAIT_TIME_MS; +import static com.example.solidconnection.community.post.service.RedisConstants.REFRESH_LIMIT_PERCENT; + import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.cache.manager.CacheManager; import com.example.solidconnection.util.RedisUtils; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -14,19 +26,6 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.stereotype.Component; -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import static com.example.solidconnection.type.RedisConstants.CREATE_CHANNEL; -import static com.example.solidconnection.type.RedisConstants.LOCK_TIMEOUT_MS; -import static com.example.solidconnection.type.RedisConstants.MAX_WAIT_TIME_MS; -import static com.example.solidconnection.type.RedisConstants.REFRESH_LIMIT_PERCENT; - @Aspect @Component @Slf4j diff --git a/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java b/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java index c5a9e0e9b..5cf59b41d 100644 --- a/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java +++ b/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java @@ -8,6 +8,7 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ThunderingHerdCaching { + String key(); String cacheManager(); diff --git a/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java b/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java index 2e489567c..581822f73 100644 --- a/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java +++ b/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java @@ -1,14 +1,13 @@ package com.example.solidconnection.cache.manager; +import java.time.Duration; +import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.stereotype.Component; -import java.time.Duration; -import java.util.Set; - @Component("customCacheManager") public class CustomCacheManager implements CacheManager { diff --git a/src/main/java/com/example/solidconnection/chat/config/CustomHandshakeHandler.java b/src/main/java/com/example/solidconnection/chat/config/CustomHandshakeHandler.java new file mode 100644 index 000000000..6c3054355 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/CustomHandshakeHandler.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.chat.config; + +import java.security.Principal; +import java.util.Map; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +// WebSocket 세션의 Principal을 결정한다. +@Component +public class CustomHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, + Map attributes) { + + Object userAttribute = attributes.get("user"); + + if (userAttribute instanceof Principal) { + Principal principal = (Principal) userAttribute; + return principal; + } + + return super.determineUser(request, wsHandler, attributes); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java b/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java new file mode 100644 index 000000000..1064eef3a --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.chat.config; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@Component +public class StompEventListener { + + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @EventListener + public void connectHandle(SessionConnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.add(accessor.getSessionId()); + } + + @EventListener + public void disconnectHandle(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.remove(accessor.getSessionId()); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompHandler.java b/src/main/java/com/example/solidconnection/chat/config/StompHandler.java new file mode 100644 index 000000000..2e99bf9c4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompHandler.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.chat.config; + +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; + +import com.example.solidconnection.chat.service.ChatService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import java.security.Principal; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StompHandler implements ChannelInterceptor { + + private static final Pattern ROOM_ID_PATTERN = Pattern.compile("^/topic/chat/(\\d+)$"); + private final ChatService chatService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + Principal user = accessor.getUser(); + if (user == null) { + throw new CustomException(AUTHENTICATION_FAILED); + } + } + + if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { + Principal user = accessor.getUser(); + if (user == null) { + throw new CustomException(AUTHENTICATION_FAILED); + } + + TokenAuthentication tokenAuthentication = (TokenAuthentication) user; + SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); + + String destination = accessor.getDestination(); + long roomId = Long.parseLong(extractRoomId(destination)); + + chatService.validateChatRoomParticipant(siteUserDetails.getSiteUser().getId(), roomId); + } + + return message; + } + + private String extractRoomId(String destination) { + if (destination == null) { + throw new CustomException(ErrorCode.INVALID_ROOM_ID); + } + + Matcher matcher = ROOM_ID_PATTERN.matcher(destination); + if (!matcher.matches()) { + throw new CustomException(ErrorCode.INVALID_ROOM_ID); + } + + return matcher.group(1); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompProperties.java b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java new file mode 100644 index 000000000..ce9663c72 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.chat.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "websocket") +public record StompProperties(ThreadPool threadPool, HeartbeatProperties heartbeat) { + + public record ThreadPool(InboundProperties inbound, OutboundProperties outbound) { + + } + + public record InboundProperties(int corePoolSize, int maxPoolSize, int queueCapacity) { + + } + + public record OutboundProperties(int corePoolSize, int maxPoolSize) { + + } + + public record HeartbeatProperties(long serverInterval, long clientInterval) { + + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java new file mode 100644 index 000000000..51259a0e1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.chat.config; + +import com.example.solidconnection.chat.config.StompProperties.HeartbeatProperties; +import com.example.solidconnection.chat.config.StompProperties.InboundProperties; +import com.example.solidconnection.chat.config.StompProperties.OutboundProperties; +import com.example.solidconnection.security.config.CorsProperties; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompHandler stompHandler; + private final StompProperties stompProperties; + private final CorsProperties corsProperties; + private final WebSocketHandshakeInterceptor webSocketHandshakeInterceptor; + private final CustomHandshakeHandler customHandshakeHandler; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + List strings = corsProperties.allowedOrigins(); + String[] allowedOrigins = strings.toArray(String[]::new); + registry.addEndpoint("/connect") + .setAllowedOrigins(allowedOrigins) + .addInterceptors(webSocketHandshakeInterceptor) + .setHandshakeHandler(customHandshakeHandler) + .withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + InboundProperties inboundProperties = stompProperties.threadPool().inbound(); + registration.interceptors(stompHandler).taskExecutor().corePoolSize(inboundProperties.corePoolSize()).maxPoolSize(inboundProperties.maxPoolSize()).queueCapacity(inboundProperties.queueCapacity()); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + OutboundProperties outboundProperties = stompProperties.threadPool().outbound(); + registration.taskExecutor().corePoolSize(outboundProperties.corePoolSize()).maxPoolSize(outboundProperties.maxPoolSize()); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("wss-heartbeat-"); + scheduler.initialize(); + HeartbeatProperties heartbeatProperties = stompProperties.heartbeat(); + registry.setApplicationDestinationPrefixes("/publish"); + registry.enableSimpleBroker("/topic").setHeartbeatValue(new long[]{heartbeatProperties.serverInterval(), heartbeatProperties.clientInterval()}).setTaskScheduler(scheduler); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/WebSocketHandshakeInterceptor.java b/src/main/java/com/example/solidconnection/chat/config/WebSocketHandshakeInterceptor.java new file mode 100644 index 000000000..e4af7a412 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/WebSocketHandshakeInterceptor.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.chat.config; + +import java.security.Principal; +import java.util.Map; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +// Principal을 WebSocket 세션에 저장하는 것에만 집중한다. +@Component +public class WebSocketHandshakeInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + Principal principal = request.getPrincipal(); + + if (principal != null) { + attributes.put("user", principal); + } + + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + } +} diff --git a/src/main/java/com/example/solidconnection/chat/controller/ChatController.java b/src/main/java/com/example/solidconnection/chat/controller/ChatController.java new file mode 100644 index 000000000..ddb1f9084 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/controller/ChatController.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.chat.controller; + +import com.example.solidconnection.chat.dto.ChatMessageResponse; +import com.example.solidconnection.chat.dto.ChatParticipantResponse; +import com.example.solidconnection.chat.dto.ChatRoomListResponse; +import com.example.solidconnection.chat.service.ChatService; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import lombok.RequiredArgsConstructor; +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.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chats") +public class ChatController { + + private final ChatService chatService; + + @GetMapping("/rooms") + public ResponseEntity getChatRooms( + @AuthorizedUser long siteUserId + ) { + ChatRoomListResponse chatRoomListResponse = chatService.getChatRooms(siteUserId); + return ResponseEntity.ok(chatRoomListResponse); + } + + @GetMapping("/rooms/{room-id}") + public ResponseEntity> getChatMessages( + @AuthorizedUser long siteUserId, + @PathVariable("room-id") Long roomId, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + SliceResponse response = chatService.getChatMessages(siteUserId, roomId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("rooms/{room-id}/partner") + public ResponseEntity getChatPartner( + @AuthorizedUser long siteUserId, + @PathVariable("room-id") Long roomId + ) { + ChatParticipantResponse response = chatService.getChatPartner(siteUserId, roomId); + return ResponseEntity.ok(response); + } + + @PutMapping("/rooms/{room-id}/read") + public ResponseEntity markChatMessagesAsRead( + @AuthorizedUser long siteUserId, + @PathVariable("room-id") Long roomId + ) { + chatService.markChatMessagesAsRead(siteUserId, roomId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java b/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java new file mode 100644 index 000000000..a7e158224 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.chat.controller; + +import com.example.solidconnection.chat.dto.ChatMessageSendRequest; +import com.example.solidconnection.chat.service.ChatService; +import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import jakarta.validation.Valid; +import java.security.Principal; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class ChatMessageController { + + private final ChatService chatService; + + @MessageMapping("/chat/{roomId}") + public void sendChatMessage( + @DestinationVariable Long roomId, + @Valid @Payload ChatMessageSendRequest chatMessageSendRequest, + Principal principal + ) { + TokenAuthentication tokenAuthentication = (TokenAuthentication) principal; + SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); + + chatService.sendChatMessage(chatMessageSendRequest, siteUserDetails.getSiteUser().getId(), roomId); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java new file mode 100644 index 000000000..def9263c8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.chat.domain; + +import com.example.solidconnection.common.BaseEntity; +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.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatAttachment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Boolean isImage; + + @Column(nullable = false, length = 500) + private String url; + + @Column(length = 500) + private String thumbnailUrl; + + @ManyToOne(fetch = FetchType.LAZY) + private ChatMessage chatMessage; + + public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) { + this.isImage = isImage; + this.url = url; + this.thumbnailUrl = thumbnailUrl; + this.chatMessage = chatMessage; + if (chatMessage != null) { + chatMessage.getChatAttachments().add(this); + } + } +} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java new file mode 100644 index 000000000..07fc99131 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.chat.domain; + +import com.example.solidconnection.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 500) + private String content; + + private long senderId; + + @ManyToOne(fetch = FetchType.LAZY) + private ChatRoom chatRoom; + + @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL) + private final List chatAttachments = new ArrayList<>(); + + public ChatMessage(String content, long senderId, ChatRoom chatRoom) { + this.content = content; + this.senderId = senderId; + this.chatRoom = chatRoom; + if (chatRoom != null) { + chatRoom.getChatMessages().add(this); + } + } +} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java b/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java new file mode 100644 index 000000000..60fc6b795 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.chat.domain; + +import com.example.solidconnection.common.BaseEntity; +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.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_chat_participant_chat_room_id_site_user_id", + columnNames = {"chat_room_id", "site_user_id"} + ) +}) +public class ChatParticipant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "site_user_id") + private long siteUserId; + + @ManyToOne(fetch = FetchType.LAZY) + private ChatRoom chatRoom; + + public ChatParticipant(long siteUserId, ChatRoom chatRoom) { + this.siteUserId = siteUserId; + this.chatRoom = chatRoom; + if (chatRoom != null) { + chatRoom.getChatParticipants().add(this); + } + } +} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java b/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java new file mode 100644 index 000000000..8c731738c --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.chat.domain; + +import com.example.solidconnection.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_chat_read_status_chat_room_id_chat_participant_id", + columnNames = {"chat_room_id", "chat_participant_id"} + ) +}) +public class ChatReadStatus extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "chat_room_id") + private long chatRoomId; + + @Column(name = "chat_participant_id") + private long chatParticipantId; + + public ChatReadStatus(long chatRoomId, long chatParticipantId) { + this.chatRoomId = chatRoomId; + this.chatParticipantId = chatParticipantId; + } +} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java b/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java new file mode 100644 index 000000000..fc159c2cd --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.chat.domain; + +import com.example.solidconnection.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRoom extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private boolean isGroup = false; + + @Column(name = "mentoring_id", unique = true) + private Long mentoringId; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL) + @BatchSize(size = 10) + private final List chatParticipants = new ArrayList<>(); + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL) + private final List chatMessages = new ArrayList<>(); + + public ChatRoom(boolean isGroup) { + this.isGroup = isGroup; + } + + public ChatRoom(Long mentoringId, boolean isGroup) { + this.mentoringId = mentoringId; + this.isGroup = isGroup; + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatAttachmentResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatAttachmentResponse.java new file mode 100644 index 000000000..44c11246b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatAttachmentResponse.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.chat.dto; + +import java.time.ZonedDateTime; + +public record ChatAttachmentResponse( + long id, + boolean isImage, + String url, + String thumbnailUrl, + ZonedDateTime createdAt +) { + + public static ChatAttachmentResponse of(long id, boolean isImage, String url, + String thumbnailUrl, ZonedDateTime createdAt) { + return new ChatAttachmentResponse(id, isImage, url, thumbnailUrl, createdAt); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java new file mode 100644 index 000000000..a3728b7fd --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.chat.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +public record ChatMessageResponse( + long id, + String content, + long senderId, + ZonedDateTime createdAt, + List attachments +) { + + public static ChatMessageResponse of(long id, String content, long senderId, + ZonedDateTime createdAt, List attachments) { + return new ChatMessageResponse(id, content, senderId, createdAt, attachments); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendRequest.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendRequest.java new file mode 100644 index 000000000..22d652a35 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendRequest.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ChatMessageSendRequest( + @NotNull(message = "메시지를 입력해주세요.") + @Size(max = 500, message = "메시지는 500자를 초과할 수 없습니다") + String content +) { + +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java new file mode 100644 index 000000000..065c7ba1c --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.chat.dto; + +import com.example.solidconnection.chat.domain.ChatMessage; + +public record ChatMessageSendResponse( + long messageId, + String content, + long senderId +) { + + public static ChatMessageSendResponse from(ChatMessage chatMessage) { + return new ChatMessageSendResponse( + chatMessage.getId(), + chatMessage.getContent(), + chatMessage.getSenderId() + ); + } + +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java new file mode 100644 index 000000000..ffa6b9b8c --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.dto; + +public record ChatParticipantResponse( + long partnerId, + String nickname, + String profileUrl +) { + + public static ChatParticipantResponse of(long partnerId, String nickname, String profileUrl) { + return new ChatParticipantResponse(partnerId, nickname, profileUrl); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatRoomListResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomListResponse.java new file mode 100644 index 000000000..add17f1d1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomListResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.dto; + +import java.util.List; + +public record ChatRoomListResponse( + List chatRooms +) { + + public static ChatRoomListResponse of(List chatRooms) { + return new ChatRoomListResponse(chatRooms); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatRoomResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomResponse.java new file mode 100644 index 000000000..69ec047fb --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomResponse.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.chat.dto; + +import java.time.ZonedDateTime; + +public record ChatRoomResponse( + long id, + String lastChatMessage, + ZonedDateTime lastReceivedTime, + ChatParticipantResponse partner, + long unReadCount +) { + + public static ChatRoomResponse of( + long id, + String lastChatMessage, + ZonedDateTime lastReceivedTime, + ChatParticipantResponse partner, + long unReadCount + ) { + return new ChatRoomResponse(id, lastChatMessage, lastReceivedTime, partner, unReadCount); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatAttachmentRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatAttachmentRepository.java new file mode 100644 index 000000000..0d2dd3051 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatAttachmentRepository.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatAttachmentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java new file mode 100644 index 000000000..ad0f15630 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatMessage; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatMessageRepository extends JpaRepository { + + @Query(""" + SELECT cm FROM ChatMessage cm + LEFT JOIN FETCH cm.chatAttachments + WHERE cm.chatRoom.id = :roomId + ORDER BY cm.createdAt DESC + """) + Slice findByRoomIdWithPaging(@Param("roomId") long roomId, Pageable pageable); + + Optional findFirstByChatRoomIdOrderByCreatedAtDesc(long chatRoomId); +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java new file mode 100644 index 000000000..4bce2d08c --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatParticipant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatParticipantRepository extends JpaRepository { + + boolean existsByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); + + Optional findByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java new file mode 100644 index 000000000..5ff82a75b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatReadStatusRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, updated_at) + VALUES (:chatRoomId, :chatParticipantId, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE updated_at = NOW(6) + """, nativeQuery = true) + void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId); +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java new file mode 100644 index 000000000..8c0b81a4b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatRoom; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatRoomRepository extends JpaRepository { + + @Query(""" + SELECT cr FROM ChatRoom cr + JOIN cr.chatParticipants cp + WHERE cp.siteUserId = :userId AND cr.isGroup = false + ORDER BY ( + SELECT MAX(cm.createdAt) + FROM ChatMessage cm + WHERE cm.chatRoom = cr + ) DESC NULLS LAST + """) + List findOneOnOneChatRoomsByUserId(@Param("userId") long userId); + + @Query(""" + SELECT COUNT(cm) FROM ChatMessage cm + LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id + AND crs.chatParticipantId = ( + SELECT cp.id FROM ChatParticipant cp + WHERE cp.chatRoom.id = :chatRoomId + AND cp.siteUserId = :userId + ) + WHERE cm.chatRoom.id = :chatRoomId + AND cm.senderId != :userId + AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt) + """) + long countUnreadMessages(@Param("chatRoomId") long chatRoomId, @Param("userId") long userId); + + ChatRoom findByMentoringId(long mentoringId); + + List findAllByMentoringIdIn(List mentoringIds); +} diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java new file mode 100644 index 000000000..ae9be659b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -0,0 +1,193 @@ +package com.example.solidconnection.chat.service; + +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTICIPANT_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTNER_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_CHAT_ROOM_STATE; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.dto.ChatAttachmentResponse; +import com.example.solidconnection.chat.dto.ChatMessageResponse; +import com.example.solidconnection.chat.dto.ChatMessageSendRequest; +import com.example.solidconnection.chat.dto.ChatMessageSendResponse; +import com.example.solidconnection.chat.dto.ChatParticipantResponse; +import com.example.solidconnection.chat.dto.ChatRoomListResponse; +import com.example.solidconnection.chat.dto.ChatRoomResponse; +import com.example.solidconnection.chat.repository.ChatMessageRepository; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; +import com.example.solidconnection.chat.repository.ChatReadStatusRepository; +import com.example.solidconnection.chat.repository.ChatRoomRepository; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ChatService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final ChatReadStatusRepository chatReadStatusRepository; + private final SiteUserRepository siteUserRepository; + + private final SimpMessageSendingOperations simpMessageSendingOperations; + + public ChatService(ChatRoomRepository chatRoomRepository, + ChatMessageRepository chatMessageRepository, + ChatParticipantRepository chatParticipantRepository, + ChatReadStatusRepository chatReadStatusRepository, + SiteUserRepository siteUserRepository, + @Lazy SimpMessageSendingOperations simpMessageSendingOperations) { + this.chatRoomRepository = chatRoomRepository; + this.chatMessageRepository = chatMessageRepository; + this.chatParticipantRepository = chatParticipantRepository; + this.chatReadStatusRepository = chatReadStatusRepository; + this.siteUserRepository = siteUserRepository; + this.simpMessageSendingOperations = simpMessageSendingOperations; + } + + @Transactional(readOnly = true) + public ChatRoomListResponse getChatRooms(long siteUserId) { + // todo : n + 1 문제 해결 필요! + List chatRooms = chatRoomRepository.findOneOnOneChatRoomsByUserId(siteUserId); + List chatRoomInfos = chatRooms.stream() + .map(chatRoom -> toChatRoomResponse(chatRoom, siteUserId)) + .toList(); + return ChatRoomListResponse.of(chatRoomInfos); + } + + private ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom, long siteUserId) { + Optional latestMessage = chatMessageRepository.findFirstByChatRoomIdOrderByCreatedAtDesc(chatRoom.getId()); + String lastChatMessage = latestMessage.map(ChatMessage::getContent).orElse(""); + ZonedDateTime lastReceivedTime = latestMessage.map(ChatMessage::getCreatedAt).orElse(null); + + ChatParticipant partnerParticipant = findPartner(chatRoom, siteUserId); + + SiteUser siteUser = siteUserRepository.findById(partnerParticipant.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + ChatParticipantResponse partner = ChatParticipantResponse.of(siteUser.getId(), siteUser.getNickname(), siteUser.getProfileImageUrl()); + + long unReadCount = chatRoomRepository.countUnreadMessages(chatRoom.getId(), siteUserId); + + return ChatRoomResponse.of(chatRoom.getId(), lastChatMessage, lastReceivedTime, partner, unReadCount); + } + + @Transactional(readOnly = true) + public SliceResponse getChatMessages(long siteUserId, long roomId, Pageable pageable) { + validateChatRoomParticipant(siteUserId, roomId); + + Slice chatMessages = chatMessageRepository.findByRoomIdWithPaging(roomId, pageable); + + List content = chatMessages.getContent().stream() + .map(this::toChatMessageResponse) + .toList(); + + return SliceResponse.of(content, chatMessages); + } + + @Transactional(readOnly = true) + public ChatParticipantResponse getChatPartner(long siteUserId, Long roomId) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(INVALID_CHAT_ROOM_STATE)); + ChatParticipant partnerParticipant = findPartner(chatRoom, siteUserId); + SiteUser siteUser = siteUserRepository.findById(partnerParticipant.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + return ChatParticipantResponse.of(siteUser.getId(), siteUser.getNickname(), siteUser.getProfileImageUrl()); + } + + private ChatParticipant findPartner(ChatRoom chatRoom, long siteUserId) { + if (chatRoom.isGroup()) { + throw new CustomException(INVALID_CHAT_ROOM_STATE); + } + return chatRoom.getChatParticipants().stream() + .filter(participant -> participant.getSiteUserId() != siteUserId) + .findFirst() + .orElseThrow(() -> new CustomException(CHAT_PARTNER_NOT_FOUND)); + } + + public void validateChatRoomParticipant(long siteUserId, long roomId) { + boolean isParticipant = chatParticipantRepository.existsByChatRoomIdAndSiteUserId(roomId, siteUserId); + if (!isParticipant) { + throw new CustomException(CHAT_PARTICIPANT_NOT_FOUND); + } + } + + private ChatMessageResponse toChatMessageResponse(ChatMessage message) { + List attachments = message.getChatAttachments().stream() + .map(attachment -> ChatAttachmentResponse.of( + attachment.getId(), + attachment.getIsImage(), + attachment.getUrl(), + attachment.getThumbnailUrl(), + attachment.getCreatedAt() + )) + .toList(); + + return ChatMessageResponse.of( + message.getId(), + message.getContent(), + message.getSenderId(), + message.getCreatedAt(), + attachments + ); + } + + @Transactional + public void markChatMessagesAsRead(long siteUserId, long roomId) { + ChatParticipant participant = chatParticipantRepository + .findByChatRoomIdAndSiteUserId(roomId, siteUserId) + .orElseThrow(() -> new CustomException(CHAT_PARTICIPANT_NOT_FOUND)); + + chatReadStatusRepository.upsertReadStatus(roomId, participant.getId()); + } + + @Transactional + public void sendChatMessage(ChatMessageSendRequest chatMessageSendRequest, long siteUserId, long roomId) { + long senderId = chatParticipantRepository.findByChatRoomIdAndSiteUserId(roomId, siteUserId) + .orElseThrow(() -> new CustomException(CHAT_PARTICIPANT_NOT_FOUND)) + .getId(); + + ChatMessage chatMessage = new ChatMessage( + chatMessageSendRequest.content(), + senderId, + chatRoomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(INVALID_CHAT_ROOM_STATE)) + ); + + chatMessageRepository.save(chatMessage); + + ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.from(chatMessage); + + simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); + } + + @Transactional + public Long createMentoringChatRoom(Long mentoringId, Long mentorId, Long menteeId) { + ChatRoom existingChatRoom = chatRoomRepository.findByMentoringId(mentoringId); + if (existingChatRoom != null) { + return existingChatRoom.getId(); + } + + // 새 채팅방 생성 + ChatRoom chatRoom = new ChatRoom(mentoringId, false); + chatRoom = chatRoomRepository.save(chatRoom); + + ChatParticipant mentorParticipant = new ChatParticipant(mentorId, chatRoom); + ChatParticipant menteeParticipant = new ChatParticipant(menteeId, chatRoom); + chatParticipantRepository.saveAll(List.of(mentorParticipant, menteeParticipant)); + + return chatRoom.getId(); + } +} diff --git a/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java b/src/main/java/com/example/solidconnection/common/BaseEntity.java similarity index 95% rename from src/main/java/com/example/solidconnection/entity/common/BaseEntity.java rename to src/main/java/com/example/solidconnection/common/BaseEntity.java index 508953f88..b3b597350 100644 --- a/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java +++ b/src/main/java/com/example/solidconnection/common/BaseEntity.java @@ -1,19 +1,18 @@ -package com.example.solidconnection.entity.common; +package com.example.solidconnection.common; + +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.MICROS; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; +import java.time.ZonedDateTime; import lombok.Getter; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.ZonedDateTime; - -import static java.time.ZoneOffset.UTC; -import static java.time.temporal.ChronoUnit.MICROS; - @MappedSuperclass @EntityListeners(AuditingEntityListener.class) @Getter diff --git a/src/main/java/com/example/solidconnection/common/VerifyStatus.java b/src/main/java/com/example/solidconnection/common/VerifyStatus.java new file mode 100644 index 000000000..d8848c1f0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/VerifyStatus.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.common; + +public enum VerifyStatus { + + PENDING, + REJECTED, + APPROVED, + ; +} diff --git a/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java similarity index 90% rename from src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java rename to src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java index 36ce3f67b..87b43eb28 100644 --- a/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java @@ -1,12 +1,11 @@ -package com.example.solidconnection.config.client; +package com.example.solidconnection.common.config.client; +import java.time.Duration; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; -import java.time.Duration; - @Configuration public class RestTemplateConfig { diff --git a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java b/src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java similarity index 95% rename from src/main/java/com/example/solidconnection/config/redis/RedisConfig.java rename to src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java index 22847dc6d..a59558993 100644 --- a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java @@ -1,4 +1,6 @@ -package com.example.solidconnection.config.redis; +package com.example.solidconnection.common.config.redis; + +import static com.example.solidconnection.community.post.service.RedisConstants.CREATE_CHANNEL; import com.example.solidconnection.cache.CacheUpdateListener; import org.springframework.beans.factory.annotation.Value; @@ -16,8 +18,6 @@ import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -import static com.example.solidconnection.type.RedisConstants.CREATE_CHANNEL; - @Configuration @EnableRedisRepositories public class RedisConfig { diff --git a/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java b/src/main/java/com/example/solidconnection/common/config/scheduler/SchedulerConfig.java similarity index 94% rename from src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java rename to src/main/java/com/example/solidconnection/common/config/scheduler/SchedulerConfig.java index 2a2cfa6a5..227b35372 100644 --- a/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/scheduler/SchedulerConfig.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.scheduler; +package com.example.solidconnection.common.config.scheduler; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.SchedulingConfigurer; diff --git a/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java b/src/main/java/com/example/solidconnection/common/config/sync/AsyncConfig.java similarity index 92% rename from src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java rename to src/main/java/com/example/solidconnection/common/config/sync/AsyncConfig.java index 417b040b3..852e3de02 100644 --- a/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/sync/AsyncConfig.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.sync; +package com.example.solidconnection.common.config.sync; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java similarity index 70% rename from src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java rename to src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java index 6d16694cc..56bb288e8 100644 --- a/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java @@ -1,28 +1,24 @@ -package com.example.solidconnection.config.web; +package com.example.solidconnection.common.config.web; -import com.example.solidconnection.custom.resolver.AuthorizedUserResolver; -import com.example.solidconnection.custom.resolver.CustomPageableHandlerMethodArgumentResolver; -import com.example.solidconnection.custom.resolver.ExpiredTokenResolver; +import com.example.solidconnection.common.resolver.AuthorizedUserResolver; +import com.example.solidconnection.common.resolver.CustomPageableHandlerMethodArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.util.List; - @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final AuthorizedUserResolver authorizedUserResolver; - private final ExpiredTokenResolver expiredTokenResolver; private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { resolvers.addAll(List.of( authorizedUserResolver, - expiredTokenResolver, customPageableHandlerMethodArgumentResolver )); } diff --git a/src/main/java/com/example/solidconnection/common/dto/SliceResponse.java b/src/main/java/com/example/solidconnection/common/dto/SliceResponse.java new file mode 100644 index 000000000..8fe5c80b8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/dto/SliceResponse.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.common.dto; + +import java.util.List; +import org.springframework.data.domain.Slice; + +public record SliceResponse( + List content, + int nextPageNumber +) { + + private static final int NO_NEXT_PAGE = -1; + private static final int BASE_NUMBER = 1; // 1-based + + public static SliceResponse of(List content, Slice slice) { + int nextPageNumber = slice.hasNext() + ? slice.getNumber() + BASE_NUMBER + 1 + : NO_NEXT_PAGE; + return new SliceResponse<>(content, nextPageNumber); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java b/src/main/java/com/example/solidconnection/common/exception/CustomAccessDeniedHandler.java similarity index 90% rename from src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java rename to src/main/java/com/example/solidconnection/common/exception/CustomAccessDeniedHandler.java index 52b1725fc..a391ba66d 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java +++ b/src/main/java/com/example/solidconnection/common/exception/CustomAccessDeniedHandler.java @@ -1,16 +1,15 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; -import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.common.response.ErrorResponse; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - @Component @RequiredArgsConstructor public class CustomAccessDeniedHandler implements AccessDeniedHandler { diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/solidconnection/common/exception/CustomAuthenticationEntryPoint.java similarity index 90% rename from src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java rename to src/main/java/com/example/solidconnection/common/exception/CustomAuthenticationEntryPoint.java index 20f0786b7..04b3d5aed 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/example/solidconnection/common/exception/CustomAuthenticationEntryPoint.java @@ -1,16 +1,15 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; -import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.common.response.ErrorResponse; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; -import java.io.IOException; - @Component @RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomException.java b/src/main/java/com/example/solidconnection/common/exception/CustomException.java similarity index 89% rename from src/main/java/com/example/solidconnection/custom/exception/CustomException.java rename to src/main/java/com/example/solidconnection/common/exception/CustomException.java index 2f1962fbf..fab42924c 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/CustomException.java +++ b/src/main/java/com/example/solidconnection/common/exception/CustomException.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; import lombok.Getter; diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java b/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java similarity index 74% rename from src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java rename to src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java index c0c610bce..5700c3044 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java +++ b/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java @@ -1,23 +1,24 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; -import com.example.solidconnection.custom.response.ErrorResponse; +import static com.example.solidconnection.common.exception.ErrorCode.DATA_INTEGRITY_VIOLATION; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; +import static com.example.solidconnection.common.exception.ErrorCode.JSON_PARSING_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.JWT_EXCEPTION; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_DEFINED_ERROR; + +import com.example.solidconnection.common.response.ErrorResponse; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import io.jsonwebtoken.JwtException; +import java.util.ArrayList; +import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; -import java.util.ArrayList; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_INPUT; -import static com.example.solidconnection.custom.exception.ErrorCode.JSON_PARSING_FAILED; -import static com.example.solidconnection.custom.exception.ErrorCode.JWT_EXCEPTION; -import static com.example.solidconnection.custom.exception.ErrorCode.NOT_DEFINED_ERROR; - @Slf4j @ControllerAdvice public class CustomExceptionHandler { @@ -56,6 +57,15 @@ public ResponseEntity handleValidationExceptions(MethodArgumentNo .body(errorResponse); } + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex) { + log.error("데이터 무결성 제약조건 위반 예외 발생 : {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(DATA_INTEGRITY_VIOLATION, "데이터 무결성 제약조건 위반 예외 발생"); + return ResponseEntity + .status(DATA_INTEGRITY_VIOLATION.getCode()) + .body(errorResponse); + } + @ExceptionHandler(JwtException.class) public ResponseEntity handleJwtException(JwtException ex) { String errorMessage = ex.getMessage(); diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java similarity index 70% rename from src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java rename to src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 1a4e46b72..4d135416e 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -1,12 +1,12 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; + +import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; +import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; -import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; -import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; - @Getter @AllArgsConstructor public enum ErrorCode { @@ -33,8 +33,8 @@ public enum ErrorCode { SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER(HttpStatus.BAD_REQUEST.value(), "회원가입 토큰이 우리 서버에서 발급되지 않았습니다."), // data not found - UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 대학교 지원 정보입니다."), - UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM(HttpStatus.NOT_FOUND.value(), "해당하는 대학교가 이번 모집 기간에 열리지 않았습니다."), + UNIV_APPLY_INFO_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 대학교 지원 정보입니다."), + UNIV_APPLY_INFO_NOT_FOUND_FOR_TERM(HttpStatus.NOT_FOUND.value(), "해당하는 대학교가 이번 모집 기간에 열리지 않았습니다."), APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "사용자의 대학 지원 정보를 찾을 수 없습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "회원을 찾을 수 없습니다."), UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "대학교를 찾을 수 없습니다."), @@ -42,6 +42,11 @@ public enum ErrorCode { COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."), GPA_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학점입니다."), LANGUAGE_TEST_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 어학성적입니다."), + NEWS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 소식지입니다."), + MENTOR_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 멘토입니다."), + REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 신고 대상입니다."), + CHAT_PARTNER_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "채팅 상대를 찾을 수 없습니다."), + CHAT_PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "채팅 참여자를 찾을 수 없습니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -51,6 +56,11 @@ public enum ErrorCode { ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + REFRESH_TOKEN_NOT_EXISTS(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰이 존재하지 않습니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST.value(), "비밀번호가 일치하지 않습니다."), + PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."), + PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."), + SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."), // s3 S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), @@ -71,12 +81,12 @@ public enum ErrorCode { PROFILE_IMAGE_NEEDED(HttpStatus.BAD_REQUEST.value(), "프로필 이미지가 필요합니다."), FIRST_CHOICE_REQUIRED(HttpStatus.BAD_REQUEST.value(), "1지망 대학교를 입력해주세요."), THIRD_CHOICE_REQUIRES_SECOND(HttpStatus.BAD_REQUEST.value(), "2지망 없이 3지망을 선택할 수 없습니다."), - DUPLICATE_UNIVERSITY_CHOICE(HttpStatus.BAD_REQUEST.value(), "지망 선택이 중복되었습니다."), + DUPLICATE_UNIV_APPLY_INFO_CHOICE(HttpStatus.BAD_REQUEST.value(), "지망 선택이 중복되었습니다."), // community INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(), "잘못된 카테고리명입니다."), INVALID_BOARD_CODE(HttpStatus.BAD_REQUEST.value(), "잘못된 게시판 코드입니다."), - INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), + INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), // todo: NOT_FOUND로 통일 필요 INVALID_POST_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 게시글만 제어할 수 있습니다."), CAN_NOT_DELETE_OR_UPDATE_QUESTION(HttpStatus.BAD_REQUEST.value(), "질문글은 수정이나 삭제할 수 없습니다."), CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES(HttpStatus.BAD_REQUEST.value(), "5개 이상의 파일을 업로드할 수 없습니다."), @@ -86,8 +96,8 @@ public enum ErrorCode { CAN_NOT_UPDATE_DEPRECATED_COMMENT(HttpStatus.BAD_REQUEST.value(), "이미 삭제된 댓글을 수정할 수 없습니다."), INVALID_POST_LIKE(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글 좋아요입니다."), DUPLICATE_POST_LIKE(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 게시글입니다."), - ALREADY_LIKED_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 대학입니다."), - NOT_LIKED_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 대학입니다."), + ALREADY_LIKED_UNIV_APPLY_INFO(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 대학입니다."), + NOT_LIKED_UNIV_APPLY_INFO(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 대학입니다."), // score INVALID_GPA_SCORE_STATUS(HttpStatus.BAD_REQUEST.value(), "학점이 승인되지 않았습니다."), @@ -96,6 +106,32 @@ public enum ErrorCode { USER_DO_NOT_HAVE_GPA(HttpStatus.BAD_REQUEST.value(), "해당 유저의 학점을 찾을 수 없음"), REJECTED_REASON_REQUIRED(HttpStatus.BAD_REQUEST.value(), "거절 사유가 필요합니다."), + // news + INVALID_NEWS_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 소식지만 제어할 수 있습니다."), + ALREADY_LIKED_NEWS(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 소식지입니다."), + NOT_LIKED_NEWS(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 소식지입니다."), + + // mentor + CHANNEL_SEQUENCE_NOT_UNIQUE(HttpStatus.BAD_REQUEST.value(), "채널의 순서가 중복되었습니다."), + CHANNEL_REGISTRATION_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "등록 가능한 채널 수를 초과하였습니다."), + ALREADY_MENTOR(HttpStatus.BAD_REQUEST.value(), "이미 멘토로 등록된 사용자입니다."), + MENTORING_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 멘토링 신청을 찾을 수 없습니다."), + UNAUTHORIZED_MENTORING(HttpStatus.FORBIDDEN.value(), "멘토링 권한이 없습니다."), + MENTORING_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토링입니다."), + + // socket + UNAUTHORIZED_SUBSCRIBE(HttpStatus.FORBIDDEN.value(), "구독 권한이 없습니다."), + INVALID_ROOM_ID(HttpStatus.BAD_REQUEST.value(), "경로의 roomId가 잘못되었습니다."), + + // report + ALREADY_REPORTED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 신고한 상태입니다."), + + // chat + INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."), + + // database + DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), + // general JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), diff --git a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java b/src/main/java/com/example/solidconnection/common/resolver/AuthorizedUser.java similarity index 85% rename from src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java rename to src/main/java/com/example/solidconnection/common/resolver/AuthorizedUser.java index fa1db7f74..ffe3a5431 100644 --- a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java +++ b/src/main/java/com/example/solidconnection/common/resolver/AuthorizedUser.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.custom.resolver; +package com.example.solidconnection.common.resolver; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -8,5 +8,6 @@ @Target({ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface AuthorizedUser { + boolean required() default true; } diff --git a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java b/src/main/java/com/example/solidconnection/common/resolver/AuthorizedUserResolver.java similarity index 60% rename from src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java rename to src/main/java/com/example/solidconnection/common/resolver/AuthorizedUserResolver.java index f4ba9fe7f..7afb08d66 100644 --- a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java +++ b/src/main/java/com/example/solidconnection/common/resolver/AuthorizedUserResolver.java @@ -1,8 +1,9 @@ -package com.example.solidconnection.custom.resolver; +package com.example.solidconnection.common.resolver; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; -import com.example.solidconnection.siteuser.domain.SiteUser; +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.userdetails.SiteUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; @@ -13,8 +14,6 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; - @Component @RequiredArgsConstructor public class AuthorizedUserResolver implements HandlerMethodArgumentResolver { @@ -22,7 +21,8 @@ public class AuthorizedUserResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(AuthorizedUser.class) - && parameter.getParameterType().equals(SiteUser.class); + && (parameter.getParameterType().equals(long.class) + || parameter.getParameterType().equals(Long.class)); } @Override @@ -30,21 +30,28 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - SiteUser siteUser = extractSiteUserFromAuthentication(); - if (parameter.getParameterAnnotation(AuthorizedUser.class).required() && siteUser == null) { + Long siteUserId = extractIdFromAuthentication(); + if (isRequired(parameter) && siteUserId == null) { throw new CustomException(AUTHENTICATION_FAILED, "로그인 상태가 아닙니다."); } - - return siteUser; + return siteUserId; } - private SiteUser extractSiteUserFromAuthentication() { + private Long extractIdFromAuthentication() { try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); SiteUserDetails principal = (SiteUserDetails) authentication.getPrincipal(); - return principal.getSiteUser(); + return principal.getSiteUser().getId(); } catch (Exception e) { return null; } } + + private boolean isRequired(MethodParameter parameter) { + if (parameter.getParameterType().isPrimitive()) { // NPE 방지를 위해 required로 간주 + return true; + } + AuthorizedUser authorizedUser = parameter.getParameterAnnotation(AuthorizedUser.class); + return authorizedUser != null && authorizedUser.required(); + } } diff --git a/src/main/java/com/example/solidconnection/custom/resolver/CustomPageableHandlerMethodArgumentResolver.java b/src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java similarity index 92% rename from src/main/java/com/example/solidconnection/custom/resolver/CustomPageableHandlerMethodArgumentResolver.java rename to src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java index 418c6867f..ecb8bc75b 100644 --- a/src/main/java/com/example/solidconnection/custom/resolver/CustomPageableHandlerMethodArgumentResolver.java +++ b/src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.custom.resolver; +package com.example.solidconnection.common.resolver; import org.springframework.data.domain.PageRequest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; diff --git a/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java b/src/main/java/com/example/solidconnection/common/response/ErrorResponse.java similarity index 64% rename from src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java rename to src/main/java/com/example/solidconnection/common/response/ErrorResponse.java index 83cc02622..68b9c259f 100644 --- a/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java +++ b/src/main/java/com/example/solidconnection/common/response/ErrorResponse.java @@ -1,7 +1,7 @@ -package com.example.solidconnection.custom.response; +package com.example.solidconnection.common.response; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; public record ErrorResponse(String message) { diff --git a/src/main/java/com/example/solidconnection/custom/response/PageResponse.java b/src/main/java/com/example/solidconnection/common/response/PageResponse.java similarity index 92% rename from src/main/java/com/example/solidconnection/custom/response/PageResponse.java rename to src/main/java/com/example/solidconnection/common/response/PageResponse.java index d1e3479d6..adf28cfc8 100644 --- a/src/main/java/com/example/solidconnection/custom/response/PageResponse.java +++ b/src/main/java/com/example/solidconnection/common/response/PageResponse.java @@ -1,8 +1,7 @@ -package com.example.solidconnection.custom.response; - -import org.springframework.data.domain.Page; +package com.example.solidconnection.common.response; import java.util.List; +import org.springframework.data.domain.Page; public record PageResponse( List content, @@ -11,6 +10,7 @@ public record PageResponse( long totalElements, int totalPages ) { + /* * 페이지 번호는 1부터 시작하는 것이 사용자 입장에서 더 직관적이기 때문에 1을 더해줌 */ diff --git a/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java index a87552796..196a48239 100644 --- a/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java +++ b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java @@ -1,10 +1,11 @@ package com.example.solidconnection.community.board.controller; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.community.board.domain.BoardCode; import com.example.solidconnection.community.post.dto.PostListResponse; import com.example.solidconnection.community.post.service.PostQueryService; -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.BoardCode; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -13,9 +14,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/boards") @@ -35,7 +33,7 @@ public ResponseEntity findAccessibleCodes() { @GetMapping("/{code}") public ResponseEntity findPostsByCodeAndCategory( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, // todo: '사용하지 않는 인자'로 인증된 유저만 접근하게 하기보다는, 다른 방식으로 접근하는것이 좋을 것 같다 @PathVariable(value = "code") String code, @RequestParam(value = "category", defaultValue = "전체") String category) { List postsByCodeAndPostCategory = postQueryService diff --git a/src/main/java/com/example/solidconnection/community/board/domain/Board.java b/src/main/java/com/example/solidconnection/community/board/domain/Board.java index fbf13b44d..4f93e9801 100644 --- a/src/main/java/com/example/solidconnection/community/board/domain/Board.java +++ b/src/main/java/com/example/solidconnection/community/board/domain/Board.java @@ -1,17 +1,11 @@ package com.example.solidconnection.community.board.domain; -import com.example.solidconnection.community.post.domain.Post; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.ArrayList; -import java.util.List; - @Entity @Getter @NoArgsConstructor @@ -24,9 +18,6 @@ public class Board { @Column(nullable = false, length = 20) private String koreanName; - @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true) - private List postList = new ArrayList<>(); - public Board(String code, String koreanName) { this.code = code; this.koreanName = koreanName; diff --git a/src/main/java/com/example/solidconnection/type/BoardCode.java b/src/main/java/com/example/solidconnection/community/board/domain/BoardCode.java similarity index 50% rename from src/main/java/com/example/solidconnection/type/BoardCode.java rename to src/main/java/com/example/solidconnection/community/board/domain/BoardCode.java index 0d161e941..33266e8ef 100644 --- a/src/main/java/com/example/solidconnection/type/BoardCode.java +++ b/src/main/java/com/example/solidconnection/community/board/domain/BoardCode.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.community.board.domain; public enum BoardCode { EUROPE, AMERICAS, ASIA, FREE; diff --git a/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java b/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java index e4f66afdd..2ab10619e 100644 --- a/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java +++ b/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java @@ -6,6 +6,7 @@ public record PostFindBoardResponse( String code, String koreanName ) { + public static PostFindBoardResponse from(Board board) { return new PostFindBoardResponse( board.getCode(), diff --git a/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java b/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java index 06dd01161..8e2c2b9d5 100644 --- a/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java +++ b/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java @@ -1,21 +1,16 @@ package com.example.solidconnection.community.board.repository; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_BOARD_CODE; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.community.board.domain.Board; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import org.springframework.data.jpa.repository.EntityGraph; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; -@Repository public interface BoardRepository extends JpaRepository { - @EntityGraph(attributePaths = {"postList"}) Optional findBoardByCode(@Param("code") String code); default Board getByCodeUsingEntityGraph(String code) { diff --git a/src/main/java/com/example/solidconnection/community/board/service/BoardService.java b/src/main/java/com/example/solidconnection/community/board/service/BoardService.java index c918f8126..8d97c8be0 100644 --- a/src/main/java/com/example/solidconnection/community/board/service/BoardService.java +++ b/src/main/java/com/example/solidconnection/community/board/service/BoardService.java @@ -6,4 +6,5 @@ @Service @RequiredArgsConstructor public class BoardService { + } diff --git a/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java b/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java index d096f6cc9..9e64a25fd 100644 --- a/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java +++ b/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java @@ -1,13 +1,12 @@ package com.example.solidconnection.community.comment.controller; +import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.community.comment.dto.CommentCreateRequest; import com.example.solidconnection.community.comment.dto.CommentCreateResponse; import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; import com.example.solidconnection.community.comment.service.CommentService; -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -28,29 +27,29 @@ public class CommentController { @PostMapping public ResponseEntity createComment( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @Valid @RequestBody CommentCreateRequest commentCreateRequest ) { - CommentCreateResponse response = commentService.createComment(siteUser, commentCreateRequest); + CommentCreateResponse response = commentService.createComment(siteUserId, commentCreateRequest); return ResponseEntity.ok().body(response); } @PatchMapping("/{comment_id}") public ResponseEntity updateComment( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("comment_id") Long commentId, @Valid @RequestBody CommentUpdateRequest commentUpdateRequest ) { - CommentUpdateResponse response = commentService.updateComment(siteUser, commentId, commentUpdateRequest); + CommentUpdateResponse response = commentService.updateComment(siteUserId, commentId, commentUpdateRequest); return ResponseEntity.ok().body(response); } @DeleteMapping("/{comment_id}") public ResponseEntity deleteCommentById( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("comment_id") Long commentId ) { - CommentDeleteResponse response = commentService.deleteCommentById(siteUser, commentId); + CommentDeleteResponse response = commentService.deleteCommentById(siteUserId, commentId); return ResponseEntity.ok().body(response); } } diff --git a/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java b/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java index abed4b8f0..b60aa5077 100644 --- a/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java +++ b/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java @@ -1,8 +1,7 @@ package com.example.solidconnection.community.comment.domain; -import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.common.BaseEntity; import com.example.solidconnection.community.post.domain.Post; -import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -14,13 +13,12 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Transient; +import java.util.ArrayList; +import java.util.List; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.ArrayList; -import java.util.List; - @Entity @Getter @NoArgsConstructor @@ -41,13 +39,15 @@ public class Comment extends BaseEntity { @Column(length = 255) private String content; + @Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false) + private boolean isDeleted = false; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") private Post post; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "site_user_id") - private SiteUser siteUser; + @Column + private long siteUserId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") @@ -60,7 +60,7 @@ public Comment(String content) { this.content = content; } - public void setParentCommentAndPostAndSiteUser(Comment parentComment, Post post, SiteUser siteUser) { + public void setParentCommentAndPostAndSiteUserId(Comment parentComment, Post post, long siteUserId) { if (this.parentComment != null) { this.parentComment.getCommentList().remove(this); @@ -74,14 +74,10 @@ public void setParentCommentAndPostAndSiteUser(Comment parentComment, Post post, this.post = post; post.getCommentList().add(this); - if (this.siteUser != null) { - this.siteUser.getCommentList().remove(this); - } - this.siteUser = siteUser; - siteUser.getCommentList().add(this); + this.siteUserId = siteUserId; } - public void setPostAndSiteUser(Post post, SiteUser siteUser) { + public void setPostAndSiteUserId(Post post, long siteUserId) { if (this.post != null) { this.post.getCommentList().remove(this); @@ -89,22 +85,14 @@ public void setPostAndSiteUser(Post post, SiteUser siteUser) { this.post = post; post.getCommentList().add(this); - if (this.siteUser != null) { - this.siteUser.getCommentList().remove(this); - } - this.siteUser = siteUser; - siteUser.getCommentList().add(this); + this.siteUserId = siteUserId; } - public void resetPostAndSiteUserAndParentComment() { + public void resetPostAndParentComment() { if (this.post != null) { this.post.getCommentList().remove(this); this.post = null; } - if (this.siteUser != null) { - this.siteUser.getCommentList().remove(this); - this.siteUser = null; - } if (this.parentComment != null) { this.parentComment.getCommentList().remove(this); this.parentComment = null; @@ -116,6 +104,6 @@ public void updateContent(String content) { } public void deprecateComment() { - this.content = null; + this.isDeleted = true; } } diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java index 13c512a0c..d055d2e2a 100644 --- a/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java @@ -17,6 +17,7 @@ public record CommentCreateRequest( Long parentId ) { + public Comment toEntity(SiteUser siteUser, Post post, Comment parentComment) { Comment comment = new Comment( @@ -24,9 +25,9 @@ public Comment toEntity(SiteUser siteUser, Post post, Comment parentComment) { ); if (parentComment == null) { - comment.setPostAndSiteUser(post, siteUser); + comment.setPostAndSiteUserId(post, siteUser.getId()); } else { - comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); + comment.setParentCommentAndPostAndSiteUserId(parentComment, post, siteUser.getId()); } return comment; } diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java index 5283bb87f..7cfb0010e 100644 --- a/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java @@ -3,4 +3,5 @@ public record CommentDeleteResponse( Long id ) { + } diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java index 6e14dab45..a6983221f 100644 --- a/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java @@ -8,4 +8,5 @@ public record CommentUpdateRequest( @Size(min = 1, max = 255, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") String content ) { + } diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java index f1fd78ad0..16446f3ee 100644 --- a/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java @@ -1,8 +1,8 @@ package com.example.solidconnection.community.comment.dto; import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; - import java.time.ZonedDateTime; public record PostFindCommentResponse( @@ -15,15 +15,15 @@ public record PostFindCommentResponse( PostFindSiteUserResponse postFindSiteUserResponse ) { - public static PostFindCommentResponse from(Boolean isOwner, Comment comment) { + public static PostFindCommentResponse from(Boolean isOwner, Comment comment, SiteUser siteUser) { return new PostFindCommentResponse( comment.getId(), getParentCommentId(comment), - comment.getContent(), + getDisplayContent(comment), isOwner, comment.getCreatedAt(), comment.getUpdatedAt(), - PostFindSiteUserResponse.from(comment.getSiteUser()) + getDisplaySiteUserResponse(comment, siteUser) ); } @@ -33,4 +33,12 @@ private static Long getParentCommentId(Comment comment) { } return null; } + + private static String getDisplayContent(Comment comment) { + return comment.isDeleted() ? "" : comment.getContent(); + } + + private static PostFindSiteUserResponse getDisplaySiteUserResponse(Comment comment, SiteUser siteUser) { + return comment.isDeleted() ? null : PostFindSiteUserResponse.from(siteUser); + } } diff --git a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java index e5feb3f04..c05cf9bd6 100644 --- a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java @@ -1,36 +1,35 @@ package com.example.solidconnection.community.comment.repository; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_COMMENT_ID; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.comment.domain.Comment; -import com.example.solidconnection.custom.exception.CustomException; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_ID; - public interface CommentRepository extends JpaRepository { @Query(value = """ - WITH RECURSIVE CommentTree AS ( - SELECT - id, parent_id, post_id, site_user_id, content, - created_at, updated_at, - 0 AS level, CAST(id AS CHAR(255)) AS path - FROM comment - WHERE post_id = :postId AND parent_id IS NULL - UNION ALL - SELECT - c.id, c.parent_id, c.post_id, c.site_user_id, c.content, - c.created_at, c.updated_at, - ct.level + 1, CONCAT(ct.path, '->', c.id) - FROM comment c - INNER JOIN CommentTree ct ON c.parent_id = ct.id - ) - SELECT * FROM CommentTree - ORDER BY path - """, nativeQuery = true) + WITH RECURSIVE CommentTree AS ( + SELECT + id, parent_id, post_id, site_user_id, content, + created_at, updated_at, is_deleted, + 0 AS level, CAST(id AS CHAR(255)) AS path + FROM comment + WHERE post_id = :postId AND parent_id IS NULL + UNION ALL + SELECT + c.id, c.parent_id, c.post_id, c.site_user_id, c.content, + c.created_at, c.updated_at, c.is_deleted, + ct.level + 1, CONCAT(ct.path, '->', c.id) + FROM comment c + INNER JOIN CommentTree ct ON c.parent_id = ct.id + ) + SELECT * FROM CommentTree + ORDER BY path + """, nativeQuery = true) List findCommentTreeByPostId(@Param("postId") Long postId); default Comment getById(Long id) { diff --git a/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java index 76138b356..81b6bb49b 100644 --- a/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java +++ b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java @@ -1,5 +1,11 @@ package com.example.solidconnection.community.comment.service; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_COMMENT_LEVEL; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.comment.domain.Comment; import com.example.solidconnection.community.comment.dto.CommentCreateRequest; import com.example.solidconnection.community.comment.dto.CommentCreateResponse; @@ -10,21 +16,18 @@ import com.example.solidconnection.community.comment.repository.CommentRepository; import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.stream.Collectors; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_LEVEL; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - @Service @RequiredArgsConstructor public class CommentService { @@ -34,19 +37,58 @@ public class CommentService { private final SiteUserRepository siteUserRepository; @Transactional(readOnly = true) - public List findCommentsByPostId(SiteUser siteUser, Long postId) { - return commentRepository.findCommentTreeByPostId(postId) + public List findCommentsByPostId(long siteUserId, Long postId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + List allComments = commentRepository.findCommentTreeByPostId(postId); + List filteredComments = filterCommentsByDeletionRules(allComments); + + Set userIds = filteredComments.stream() + .map(Comment::getSiteUserId) + .collect(Collectors.toSet()); + + Map userMap = siteUserRepository.findAllById(userIds) .stream() - .map(comment -> PostFindCommentResponse.from(isOwner(comment, siteUser), comment)) + .collect(Collectors.toMap(SiteUser::getId, user -> user)); + + return filteredComments.stream() + .map(comment -> PostFindCommentResponse.from( + isOwner(comment, siteUser), comment, userMap.get(comment.getSiteUserId()))) .collect(Collectors.toList()); } + private List filterCommentsByDeletionRules(List comments) { + Map> commentsByParent = comments.stream() + .filter(comment -> comment.getParentComment() != null) + .collect(Collectors.groupingBy(comment -> comment.getParentComment().getId())); + + List result = new ArrayList<>(); + + List parentComments = comments.stream() + .filter(comment -> comment.getParentComment() == null) + .toList(); + for (Comment parent : parentComments) { + List children = commentsByParent.getOrDefault(parent.getId(), List.of()); + boolean allDeleted = parent.isDeleted() && + children.stream().allMatch(Comment::isDeleted); + if (!allDeleted) { + result.add(parent); + result.addAll(children.stream() + .filter(child -> !child.isDeleted()) + .toList()); + } + } + return result; + } + private Boolean isOwner(Comment comment, SiteUser siteUser) { - return comment.getSiteUser().getId().equals(siteUser.getId()); + return Objects.equals(comment.getSiteUserId(), siteUser.getId()); } @Transactional - public CommentCreateResponse createComment(SiteUser siteUser, CommentCreateRequest commentCreateRequest) { + public CommentCreateResponse createComment(long siteUserId, CommentCreateRequest commentCreateRequest) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Post post = postRepository.getById(commentCreateRequest.postId()); Comment parentComment = null; @@ -54,14 +96,7 @@ public CommentCreateResponse createComment(SiteUser siteUser, CommentCreateReque parentComment = commentRepository.getById(commentCreateRequest.parentId()); validateCommentDepth(parentComment); } - - /* - * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, - * siteUser 에 postList 를 FetchType.EAGER 로 설정할 것인지, - * post 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. - */ - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - Comment comment = commentCreateRequest.toEntity(siteUser1, post, parentComment); + Comment comment = commentCreateRequest.toEntity(siteUser, post, parentComment); Comment createdComment = commentRepository.save(comment); return CommentCreateResponse.from(createdComment); @@ -75,7 +110,9 @@ private void validateCommentDepth(Comment parentComment) { } @Transactional - public CommentUpdateResponse updateComment(SiteUser siteUser, Long commentId, CommentUpdateRequest commentUpdateRequest) { + public CommentUpdateResponse updateComment(long siteUserId, Long commentId, CommentUpdateRequest commentUpdateRequest) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Comment comment = commentRepository.getById(commentId); validateDeprecated(comment); validateOwnership(comment, siteUser); @@ -92,7 +129,9 @@ private void validateDeprecated(Comment comment) { } @Transactional - public CommentDeleteResponse deleteCommentById(SiteUser siteUser, Long commentId) { + public CommentDeleteResponse deleteCommentById(long siteUserId, Long commentId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Comment comment = commentRepository.getById(commentId); validateOwnership(comment, siteUser); @@ -100,18 +139,18 @@ public CommentDeleteResponse deleteCommentById(SiteUser siteUser, Long commentId // 대댓글인 경우 Comment parentComment = comment.getParentComment(); // 대댓글을 삭제합니다. - comment.resetPostAndSiteUserAndParentComment(); + comment.resetPostAndParentComment(); commentRepository.deleteById(commentId); // 대댓글 삭제 이후, 부모댓글이 무의미하다면 이역시 삭제합니다. - if (parentComment.getCommentList().isEmpty() && parentComment.getContent() == null) { - parentComment.resetPostAndSiteUserAndParentComment(); + if (parentComment.getCommentList().isEmpty() && parentComment.isDeleted()) { + parentComment.resetPostAndParentComment(); commentRepository.deleteById(parentComment.getId()); } } else { // 댓글인 경우 if (comment.getCommentList().isEmpty()) { // 대댓글이 없는 경우 - comment.resetPostAndSiteUserAndParentComment(); + comment.resetPostAndParentComment(); commentRepository.deleteById(commentId); } else { // 대댓글이 있는 경우 @@ -122,7 +161,7 @@ public CommentDeleteResponse deleteCommentById(SiteUser siteUser, Long commentId } private void validateOwnership(Comment comment, SiteUser siteUser) { - if (!comment.getSiteUser().getId().equals(siteUser.getId())) { + if (!Objects.equals(comment.getSiteUserId(), siteUser.getId())) { throw new CustomException(INVALID_POST_ACCESS); } } diff --git a/src/main/java/com/example/solidconnection/community/post/controller/PostController.java b/src/main/java/com/example/solidconnection/community/post/controller/PostController.java index ee422930a..88ea58695 100644 --- a/src/main/java/com/example/solidconnection/community/post/controller/PostController.java +++ b/src/main/java/com/example/solidconnection/community/post/controller/PostController.java @@ -1,5 +1,6 @@ package com.example.solidconnection.community.post.controller; +import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.community.post.dto.PostCreateRequest; import com.example.solidconnection.community.post.dto.PostCreateResponse; import com.example.solidconnection.community.post.dto.PostDeleteResponse; @@ -11,9 +12,9 @@ import com.example.solidconnection.community.post.service.PostCommandService; import com.example.solidconnection.community.post.service.PostLikeService; import com.example.solidconnection.community.post.service.PostQueryService; -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; +import java.util.Collections; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -27,9 +28,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.util.Collections; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/posts") @@ -41,20 +39,20 @@ public class PostController { @PostMapping public ResponseEntity createPost( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @Valid @RequestPart("postCreateRequest") PostCreateRequest postCreateRequest, @RequestParam(value = "file", required = false) List imageFile ) { if (imageFile == null) { imageFile = Collections.emptyList(); } - PostCreateResponse post = postCommandService.createPost(siteUser, postCreateRequest, imageFile); + PostCreateResponse post = postCommandService.createPost(siteUserId, postCreateRequest, imageFile); return ResponseEntity.ok().body(post); } @PatchMapping(value = "/{post_id}") public ResponseEntity updatePost( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("post_id") Long postId, @Valid @RequestPart("postUpdateRequest") PostUpdateRequest postUpdateRequest, @RequestParam(value = "file", required = false) List imageFile @@ -63,44 +61,44 @@ public ResponseEntity updatePost( imageFile = Collections.emptyList(); } PostUpdateResponse postUpdateResponse = postCommandService.updatePost( - siteUser, postId, postUpdateRequest, imageFile + siteUserId, postId, postUpdateRequest, imageFile ); return ResponseEntity.ok().body(postUpdateResponse); } @GetMapping("/{post_id}") public ResponseEntity findPostById( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("post_id") Long postId ) { - PostFindResponse postFindResponse = postQueryService.findPostById(siteUser, postId); + PostFindResponse postFindResponse = postQueryService.findPostById(siteUserId, postId); return ResponseEntity.ok().body(postFindResponse); } @DeleteMapping(value = "/{post_id}") public ResponseEntity deletePostById( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("post_id") Long postId ) { - PostDeleteResponse postDeleteResponse = postCommandService.deletePostById(siteUser, postId); + PostDeleteResponse postDeleteResponse = postCommandService.deletePostById(siteUserId, postId); return ResponseEntity.ok().body(postDeleteResponse); } @PostMapping(value = "/{post_id}/like") public ResponseEntity likePost( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("post_id") Long postId ) { - PostLikeResponse postLikeResponse = postLikeService.likePost(siteUser, postId); + PostLikeResponse postLikeResponse = postLikeService.likePost(siteUserId, postId); return ResponseEntity.ok().body(postLikeResponse); } @DeleteMapping(value = "/{post_id}/like") public ResponseEntity dislikePost( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @PathVariable("post_id") Long postId ) { - PostDislikeResponse postDislikeResponse = postLikeService.dislikePost(siteUser, postId); + PostDislikeResponse postDislikeResponse = postLikeService.dislikePost(siteUserId, postId); return ResponseEntity.ok().body(postDislikeResponse); } } diff --git a/src/main/java/com/example/solidconnection/community/post/domain/Post.java b/src/main/java/com/example/solidconnection/community/post/domain/Post.java index 4d96b9b22..190861131 100644 --- a/src/main/java/com/example/solidconnection/community/post/domain/Post.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/Post.java @@ -1,31 +1,24 @@ package com.example.solidconnection.community.post.domain; -import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.common.BaseEntity; import com.example.solidconnection.community.comment.domain.Comment; -import com.example.solidconnection.entity.common.BaseEntity; import com.example.solidconnection.community.post.dto.PostUpdateRequest; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.PostCategory; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.BatchSize; -import java.util.ArrayList; -import java.util.List; - @Entity @Getter @NoArgsConstructor @@ -51,13 +44,12 @@ public class Post extends BaseEntity { @Enumerated(EnumType.STRING) private PostCategory category; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "board_code") - private Board board; + @Column + private String boardCode; + + @Column + private long siteUserId; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "site_user_id") - private SiteUser siteUser; @BatchSize(size = 20) @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) @@ -79,29 +71,9 @@ public Post(String title, String content, Boolean isQuestion, Long likeCount, Lo this.category = category; } - public void setBoardAndSiteUser(Board board, SiteUser siteUser) { - if (this.board != null) { - this.board.getPostList().remove(this); - } - this.board = board; - board.getPostList().add(this); - - if (this.siteUser != null) { - this.siteUser.getPostList().remove(this); - } - this.siteUser = siteUser; - siteUser.getPostList().add(this); - } - - public void resetBoardAndSiteUser() { - if (this.board != null) { - this.board.getPostList().remove(this); - this.board = null; - } - if (this.siteUser != null) { - this.siteUser.getPostList().remove(this); - this.siteUser = null; - } + public void setBoardAndSiteUserId(String boardCode, long siteUserId) { + this.boardCode = boardCode; + this.siteUserId = siteUserId; } public void update(PostUpdateRequest postUpdateRequest) { diff --git a/src/main/java/com/example/solidconnection/community/post/domain/PostCategory.java b/src/main/java/com/example/solidconnection/community/post/domain/PostCategory.java new file mode 100644 index 000000000..3f8819749 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/domain/PostCategory.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.community.post.domain; + +public enum PostCategory { + 전체, 자유, 질문 +} diff --git a/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java b/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java index bbe1ff361..6aa75f6a5 100644 --- a/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java @@ -1,6 +1,5 @@ package com.example.solidconnection.community.post.domain; -import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -26,33 +25,21 @@ public class PostLike { @JoinColumn(name = "post_id") private Post post; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "site_user_id") - private SiteUser siteUser; + private long siteUserId; - public void setPostAndSiteUser(Post post, SiteUser siteUser) { + public void setPostAndSiteUserId(Post post, long siteUserId) { if (this.post != null) { this.post.getPostLikeList().remove(this); } this.post = post; post.getPostLikeList().add(this); - - if (this.siteUser != null) { - this.siteUser.getPostLikeList().remove(this); - } - this.siteUser = siteUser; - siteUser.getPostLikeList().add(this); + this.siteUserId = siteUserId; } - public void resetPostAndSiteUser() { + public void resetPost() { if (this.post != null) { this.post.getPostLikeList().remove(this); } this.post = null; - - if (this.siteUser != null) { - this.siteUser.getPostLikeList().remove(this); - } - this.siteUser = null; } } diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java index 5e6590b20..485897510 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java @@ -2,8 +2,8 @@ import com.example.solidconnection.community.board.domain.Board; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.PostCategory; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -36,7 +36,7 @@ public Post toEntity(SiteUser siteUser, Board board) { 0L, PostCategory.valueOf(this.postCategory) ); - post.setBoardAndSiteUser(board, siteUser); + post.setBoardAndSiteUserId(board.getCode(), siteUser.getId()); return post; } } diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java index f98f5264f..e34241750 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java @@ -3,4 +3,5 @@ public record PostDeleteResponse( Long id ) { + } diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java index 83ffc8305..3c77adebe 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java @@ -6,6 +6,7 @@ public record PostDislikeResponse( Long likeCount, Boolean isLiked ) { + public static PostDislikeResponse from(Post post) { return new PostDislikeResponse( post.getLikeCount(), diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java index 648bdb72c..68bb6fd01 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java @@ -1,7 +1,6 @@ package com.example.solidconnection.community.post.dto; import com.example.solidconnection.community.post.domain.PostImage; - import java.util.List; import java.util.stream.Collectors; @@ -9,6 +8,7 @@ public record PostFindPostImageResponse( Long id, String url ) { + public static PostFindPostImageResponse from(PostImage postImage) { return new PostFindPostImageResponse( postImage.getId(), diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java index 735defac1..e6bc6a0fa 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java @@ -4,7 +4,6 @@ import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; - import java.time.ZonedDateTime; import java.util.List; diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java index 35b2840c0..b787dce7b 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java @@ -6,6 +6,7 @@ public record PostLikeResponse( Long likeCount, Boolean isLiked ) { + public static PostLikeResponse from(Post post) { return new PostLikeResponse( post.getLikeCount(), diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java index f02af017e..7c4871419 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java @@ -1,8 +1,7 @@ package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.community.post.domain.PostImage; import com.example.solidconnection.community.post.domain.Post; - +import com.example.solidconnection.community.post.domain.PostImage; import java.time.ZonedDateTime; import java.util.List; import java.util.stream.Collectors; @@ -16,7 +15,7 @@ public record PostListResponse( ZonedDateTime createdAt, ZonedDateTime updatedAt, String postCategory, - String url + String postThumbnailUrl ) { public static PostListResponse from(Post post) { diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java index 339be3519..c82c98ce9 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java @@ -16,4 +16,5 @@ public record PostUpdateRequest( @Size(min = 1, max = 1000, message = "댓글 내용은 최소 1자 이상, 최대 255자 이하여야 합니다.") String content ) { + } diff --git a/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java index 5c35f031d..2c09eefb3 100644 --- a/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java @@ -5,6 +5,7 @@ public record PostUpdateResponse( Long id ) { + public static PostUpdateResponse from(Post post) { return new PostUpdateResponse( post.getId() diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java index 54c43f375..81d6f0a32 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java @@ -2,8 +2,7 @@ import com.example.solidconnection.community.post.domain.PostImage; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -@Repository public interface PostImageRepository extends JpaRepository { + } diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java index 417e97310..4fa3d3e72 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java @@ -1,23 +1,19 @@ package com.example.solidconnection.community.post.repository; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_LIKE; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.domain.PostLike; -import com.example.solidconnection.siteuser.domain.SiteUser; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_LIKE; - -@Repository public interface PostLikeRepository extends JpaRepository { - Optional findPostLikeByPostAndSiteUser(Post post, SiteUser siteUser); + Optional findPostLikeByPostAndSiteUserId(Post post, long siteUserId); - default PostLike getByPostAndSiteUser(Post post, SiteUser siteUser) { - return findPostLikeByPostAndSiteUser(post, siteUser) + default PostLike getByPostAndSiteUserId(Post post, long siteUserId) { + return findPostLikeByPostAndSiteUserId(post, siteUserId) .orElseThrow(() -> new CustomException(INVALID_POST_LIKE)); } } diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index 336189b05..de16d8ab1 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -1,43 +1,43 @@ package com.example.solidconnection.community.post.repository; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_ID; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.post.domain.Post; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ID; -@Repository public interface PostRepository extends JpaRepository { - @EntityGraph(attributePaths = {"postImageList", "board", "siteUser"}) + List findByBoardCode(String boardCode); + + @EntityGraph(attributePaths = {"postImageList"}) Optional findPostById(Long id); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - UPDATE Post p SET p.likeCount = p.likeCount - 1 - WHERE p.id = :postId AND p.likeCount > 0 - """) + UPDATE Post p SET p.likeCount = p.likeCount - 1 + WHERE p.id = :postId AND p.likeCount > 0 + """) void decreaseLikeCount(@Param("postId") Long postId); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - UPDATE Post p SET p.likeCount = p.likeCount + 1 - WHERE p.id = :postId - """) + UPDATE Post p SET p.likeCount = p.likeCount + 1 + WHERE p.id = :postId + """) void increaseLikeCount(@Param("postId") Long postId); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - UPDATE Post p SET p.viewCount = p.viewCount + :count - WHERE p.id = :postId - """) + UPDATE Post p SET p.viewCount = p.viewCount + :count + WHERE p.id = :postId + """) void increaseViewCount(@Param("postId") Long postId, @Param("count") Long count); default Post getByIdUsingEntityGraph(Long id) { diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java index b95cbcf1b..4b3b8d15a 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java @@ -1,8 +1,16 @@ package com.example.solidconnection.community.post.service; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_CATEGORY; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.board.domain.Board; import com.example.solidconnection.community.board.repository.BoardRepository; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.domain.PostImage; import com.example.solidconnection.community.post.dto.PostCreateRequest; import com.example.solidconnection.community.post.dto.PostCreateResponse; @@ -10,56 +18,44 @@ import com.example.solidconnection.community.post.dto.PostUpdateRequest; import com.example.solidconnection.community.post.dto.PostUpdateResponse; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; -import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.type.PostCategory; import com.example.solidconnection.util.RedisUtils; +import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.EnumUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - @Service @RequiredArgsConstructor public class PostCommandService { private final PostRepository postRepository; private final BoardRepository boardRepository; + private final SiteUserRepository siteUserRepository; private final S3Service s3Service; private final RedisService redisService; private final RedisUtils redisUtils; - private final SiteUserRepository siteUserRepository; @Transactional - public PostCreateResponse createPost(SiteUser siteUser, PostCreateRequest postCreateRequest, + public PostCreateResponse createPost(long siteUserId, PostCreateRequest postCreateRequest, List imageFile) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); // 유효성 검증 validatePostCategory(postCreateRequest.postCategory()); validateFileSize(imageFile); // 객체 생성 Board board = boardRepository.getByCode(postCreateRequest.boardCode()); - /* - * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, - * siteUser 에 postList 를 FetchType.EAGER 로 설정할 것인지, - * post 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. - */ - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - Post post = postCreateRequest.toEntity(siteUser1, board); + Post post = postCreateRequest.toEntity(siteUser, board); + // 이미지 처리 savePostImages(imageFile, post); Post createdPost = postRepository.save(post); @@ -68,8 +64,10 @@ public PostCreateResponse createPost(SiteUser siteUser, PostCreateRequest postCr } @Transactional - public PostUpdateResponse updatePost(SiteUser siteUser, Long postId, PostUpdateRequest postUpdateRequest, + public PostUpdateResponse updatePost(long siteUserId, Long postId, PostUpdateRequest postUpdateRequest, List imageFile) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); // 유효성 검증 Post post = postRepository.getById(postId); validateOwnership(post, siteUser); @@ -98,13 +96,14 @@ private void savePostImages(List imageFile, Post post) { } @Transactional - public PostDeleteResponse deletePostById(SiteUser siteUser, Long postId) { + public PostDeleteResponse deletePostById(long siteUserId, Long postId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Post post = postRepository.getById(postId); validateOwnership(post, siteUser); validateQuestion(post); removePostImages(post); - post.resetBoardAndSiteUser(); // cache out redisService.deleteKey(redisUtils.getPostViewCountRedisKey(postId)); postRepository.deleteById(post.getId()); @@ -113,7 +112,7 @@ public PostDeleteResponse deletePostById(SiteUser siteUser, Long postId) { } private void validateOwnership(Post post, SiteUser siteUser) { - if (!post.getSiteUser().getId().equals(siteUser.getId())) { + if (!Objects.equals(post.getSiteUserId(), siteUser.getId())) { throw new CustomException(INVALID_POST_ACCESS); } } diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java b/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java index 98d1a239f..8f9eeb318 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java @@ -1,12 +1,15 @@ package com.example.solidconnection.community.post.service; +import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_POST_LIKE; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.domain.PostLike; import com.example.solidconnection.community.post.dto.PostDislikeResponse; import com.example.solidconnection.community.post.dto.PostLikeResponse; import com.example.solidconnection.community.post.repository.PostLikeRepository; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; @@ -14,9 +17,6 @@ import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; -import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - @Service @RequiredArgsConstructor public class PostLikeService { @@ -26,18 +26,13 @@ public class PostLikeService { private final SiteUserRepository siteUserRepository; @Transactional(isolation = Isolation.READ_COMMITTED) - public PostLikeResponse likePost(SiteUser siteUser, Long postId) { + public PostLikeResponse likePost(long siteUserId, Long postId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Post post = postRepository.getById(postId); validateDuplicatePostLike(post, siteUser); PostLike postLike = new PostLike(); - - /* - * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, - * siteUser 에 postList 를 FetchType.EAGER 로 설정할 것인지, - * post 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. - */ - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - postLike.setPostAndSiteUser(post, siteUser1); + postLike.setPostAndSiteUserId(post, siteUser.getId()); postLikeRepository.save(postLike); postRepository.increaseLikeCount(post.getId()); @@ -45,11 +40,13 @@ public PostLikeResponse likePost(SiteUser siteUser, Long postId) { } @Transactional(isolation = Isolation.READ_COMMITTED) - public PostDislikeResponse dislikePost(SiteUser siteUser, Long postId) { + public PostDislikeResponse dislikePost(long siteUserId, Long postId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Post post = postRepository.getById(postId); - PostLike postLike = postLikeRepository.getByPostAndSiteUser(post, siteUser); - postLike.resetPostAndSiteUser(); + PostLike postLike = postLikeRepository.getByPostAndSiteUserId(post, siteUser.getId()); + postLike.resetPost(); postLikeRepository.deleteById(postLike.getId()); postRepository.decreaseLikeCount(post.getId()); @@ -57,7 +54,7 @@ public PostDislikeResponse dislikePost(SiteUser siteUser, Long postId) { } private void validateDuplicatePostLike(Post post, SiteUser siteUser) { - if (postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser).isPresent()) { + if (postLikeRepository.findPostLikeByPostAndSiteUserId(post, siteUser.getId()).isPresent()) { throw new CustomException(DUPLICATE_POST_LIKE); } } diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java index 66cbb5faa..85657a8a7 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java @@ -1,34 +1,35 @@ package com.example.solidconnection.community.post.service; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_BOARD_CODE; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_CATEGORY; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.domain.BoardCode; import com.example.solidconnection.community.board.dto.PostFindBoardResponse; -import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; -import com.example.solidconnection.community.post.dto.PostListResponse; import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; import com.example.solidconnection.community.comment.service.CommentService; -import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.dto.PostFindPostImageResponse; import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.dto.PostListResponse; import com.example.solidconnection.community.post.repository.PostLikeRepository; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.service.RedisService; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; -import com.example.solidconnection.type.BoardCode; -import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.util.RedisUtils; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.EnumUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.stream.Collectors; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; - @Service @RequiredArgsConstructor public class PostQueryService { @@ -36,6 +37,7 @@ public class PostQueryService { private final BoardRepository boardRepository; private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; + private final SiteUserRepository siteUserRepository; private final CommentService commentService; private final RedisService redisService; private final RedisUtils redisUtils; @@ -45,23 +47,28 @@ public List findPostsByCodeAndPostCategory(String code, String String boardCode = validateCode(code); PostCategory postCategory = validatePostCategory(category); + boardRepository.getByCode(boardCode); + List postList = postRepository.findByBoardCode(boardCode); - Board board = boardRepository.getByCodeUsingEntityGraph(boardCode); - List postList = getPostListByPostCategory(board.getPostList(), postCategory); - - return PostListResponse.from(postList); + return PostListResponse.from(getPostListByPostCategory(postList, postCategory)); } @Transactional(readOnly = true) - public PostFindResponse findPostById(SiteUser siteUser, Long postId) { + public PostFindResponse findPostById(long siteUserId, Long postId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Post post = postRepository.getByIdUsingEntityGraph(postId); Boolean isOwner = getIsOwner(post, siteUser); Boolean isLiked = getIsLiked(post, siteUser); - PostFindBoardResponse boardPostFindResultDTO = PostFindBoardResponse.from(post.getBoard()); - PostFindSiteUserResponse siteUserPostFindResultDTO = PostFindSiteUserResponse.from(post.getSiteUser()); + Board board = boardRepository.findBoardByCode(post.getBoardCode()) + .orElseThrow(() -> new CustomException(INVALID_BOARD_CODE)); + SiteUser postAuthor = siteUserRepository.findById(post.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + PostFindBoardResponse boardPostFindResultDTO = PostFindBoardResponse.from(board); + PostFindSiteUserResponse siteUserPostFindResultDTO = PostFindSiteUserResponse.from(postAuthor); List postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList()); - List commentFindResultDTOList = commentService.findCommentsByPostId(siteUser, postId); + List commentFindResultDTOList = commentService.findCommentsByPostId(siteUser.getId(), postId); // caching && 어뷰징 방지 if (redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), postId))) { @@ -81,11 +88,11 @@ private String validateCode(String code) { } private Boolean getIsOwner(Post post, SiteUser siteUser) { - return post.getSiteUser().getId().equals(siteUser.getId()); + return Objects.equals(post.getSiteUserId(), siteUser.getId()); } private Boolean getIsLiked(Post post, SiteUser siteUser) { - return postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser) + return postLikeRepository.findPostLikeByPostAndSiteUserId(post, siteUser.getId()) .isPresent(); } diff --git a/src/main/java/com/example/solidconnection/type/RedisConstants.java b/src/main/java/com/example/solidconnection/community/post/service/RedisConstants.java similarity index 90% rename from src/main/java/com/example/solidconnection/type/RedisConstants.java rename to src/main/java/com/example/solidconnection/community/post/service/RedisConstants.java index 7d4c7f2c9..46260596c 100644 --- a/src/main/java/com/example/solidconnection/type/RedisConstants.java +++ b/src/main/java/com/example/solidconnection/community/post/service/RedisConstants.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.community.post.service; import lombok.Getter; diff --git a/src/main/java/com/example/solidconnection/service/RedisService.java b/src/main/java/com/example/solidconnection/community/post/service/RedisService.java similarity index 75% rename from src/main/java/com/example/solidconnection/service/RedisService.java rename to src/main/java/com/example/solidconnection/community/post/service/RedisService.java index 36be7b66f..7b701fc2b 100644 --- a/src/main/java/com/example/solidconnection/service/RedisService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/RedisService.java @@ -1,19 +1,18 @@ -package com.example.solidconnection.service; +package com.example.solidconnection.community.post.service; +import static com.example.solidconnection.community.post.service.RedisConstants.VALIDATE_VIEW_COUNT_TTL; +import static com.example.solidconnection.community.post.service.RedisConstants.VIEW_COUNT_TTL; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; -import java.util.Collections; -import java.util.concurrent.TimeUnit; - -import static com.example.solidconnection.type.RedisConstants.VALIDATE_VIEW_COUNT_TTL; -import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_TTL; - @Service -public class RedisService { +public class RedisService { // todo: 정말 필요한지 고민 필요 private final RedisTemplate redisTemplate; private final RedisScript incrViewCountLuaScript; @@ -40,7 +39,7 @@ public Long getAndDelete(String key) { public boolean isPresent(String key) { return Boolean.TRUE.equals(redisTemplate.opsForValue() - .setIfAbsent(key, "1", Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()), TimeUnit.SECONDS)); + .setIfAbsent(key, "1", Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()), TimeUnit.SECONDS)); } public boolean isKeyExists(String key) { diff --git a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java similarity index 95% rename from src/main/java/com/example/solidconnection/service/UpdateViewCountService.java rename to src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java index 2b67e25ec..89a6f341a 100644 --- a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.service; +package com.example.solidconnection.community.post.service; import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.repository.PostRepository; diff --git a/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java b/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java deleted file mode 100644 index 785283d7d..000000000 --- a/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.solidconnection.config.security; - -import com.example.solidconnection.custom.security.provider.ExpiredTokenAuthenticationProvider; -import com.example.solidconnection.custom.security.provider.SiteUserAuthenticationProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.ProviderManager; - -@RequiredArgsConstructor -@Configuration -public class AuthenticationManagerConfig { - - private final SiteUserAuthenticationProvider siteUserAuthenticationProvider; - private final ExpiredTokenAuthenticationProvider expiredTokenAuthenticationProvider; - - @Bean - public AuthenticationManager authenticationManager() { - return new ProviderManager( - siteUserAuthenticationProvider, - expiredTokenAuthenticationProvider - ); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/exception/JwtExpiredTokenException.java b/src/main/java/com/example/solidconnection/custom/exception/JwtExpiredTokenException.java deleted file mode 100644 index b0a52e9fa..000000000 --- a/src/main/java/com/example/solidconnection/custom/exception/JwtExpiredTokenException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.solidconnection.custom.exception; - -import org.springframework.security.core.AuthenticationException; - -public class JwtExpiredTokenException extends AuthenticationException { - - public JwtExpiredTokenException(String msg) { - super(msg); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java deleted file mode 100644 index 5de4ad95a..000000000 --- a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.solidconnection.custom.resolver; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 -@Target({ElementType.PARAMETER}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ExpiredToken { -} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java deleted file mode 100644 index 7547a1d61..000000000 --- a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.solidconnection.custom.resolver; - -import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; -import lombok.RequiredArgsConstructor; -import org.springframework.core.MethodParameter; -import org.springframework.security.core.context.SecurityContextHolder; -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; - -// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 -@Component -@RequiredArgsConstructor -public class ExpiredTokenResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(ExpiredToken.class) - && parameter.getParameterType().equals(ExpiredTokenAuthentication.class); - } - - @Override - public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) throws Exception { - try { - return SecurityContextHolder.getContext().getAuthentication(); - } catch (Exception e) { - return null; - } - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/aspect/AdminAuthorizationAspect.java b/src/main/java/com/example/solidconnection/custom/security/aspect/AdminAuthorizationAspect.java deleted file mode 100644 index 20e8c27c8..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/aspect/AdminAuthorizationAspect.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.solidconnection.custom.security.aspect; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.security.annotation.RequireAdminAccess; -import com.example.solidconnection.siteuser.domain.SiteUser; -import lombok.RequiredArgsConstructor; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.stereotype.Component; - -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; -import static com.example.solidconnection.type.Role.ADMIN; - -@Aspect -@Component -@RequiredArgsConstructor -public class AdminAuthorizationAspect { - - @Around("@annotation(requireAdminAccess)") - public Object checkAdminAccess(ProceedingJoinPoint joinPoint, - RequireAdminAccess requireAdminAccess) throws Throwable { - SiteUser siteUser = null; - for (Object arg : joinPoint.getArgs()) { - if (arg instanceof SiteUser) { - siteUser = (SiteUser) arg; - break; - } - } - if (siteUser == null || !ADMIN.equals(siteUser.getRole())) { - throw new CustomException(ACCESS_DENIED); - } - return joinPoint.proceed(); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java deleted file mode 100644 index 061484674..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.solidconnection.custom.security.authentication; - -// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 -public class ExpiredTokenAuthentication extends JwtAuthentication { - - public ExpiredTokenAuthentication(String token) { - super(token, null); - setAuthenticated(false); - } - - public ExpiredTokenAuthentication(String token, String subject) { - super(token, subject); - setAuthenticated(false); - } - - public String getSubject() { - return (String) getPrincipal(); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java deleted file mode 100644 index 6c9f2fa21..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.solidconnection.custom.security.authentication; - -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Collections; - -public abstract class JwtAuthentication extends AbstractAuthenticationToken { - - private final String credentials; - - private final Object principal; - - public JwtAuthentication(String token, Object principal) { - super(principal instanceof UserDetails ? - ((UserDetails) principal).getAuthorities() : - Collections.emptyList()); - this.credentials = token; - this.principal = principal; - } - - @Override - public Object getCredentials() { - return this.credentials; - } - - @Override - public Object getPrincipal() { - return this.principal; - } - - public final String getToken() { - return (String) getCredentials(); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java deleted file mode 100644 index 3387cee55..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.solidconnection.custom.security.authentication; - -import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; - -public class SiteUserAuthentication extends JwtAuthentication { - - public SiteUserAuthentication(String token) { - super(token, null); - setAuthenticated(false); - } - - public SiteUserAuthentication(String token, SiteUserDetails principal) { - super(token, principal); - setAuthenticated(true); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java deleted file mode 100644 index 3f5bce556..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.example.solidconnection.custom.security.filter; - -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; -import com.example.solidconnection.custom.security.authentication.JwtAuthentication; -import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -import static com.example.solidconnection.util.JwtUtils.isExpired; -import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; - - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtProperties jwtProperties; - private final AuthenticationManager authenticationManager; - - @Override - public void doFilterInternal(@NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain) throws ServletException, IOException { - String token = parseTokenFromRequest(request); - if (token == null) { - filterChain.doFilter(request, response); - return; - } - - JwtAuthentication authToken = createAuthentication(token); - Authentication auth = authenticationManager.authenticate(authToken); - SecurityContextHolder.getContext().setAuthentication(auth); - - filterChain.doFilter(request, response); - } - - private JwtAuthentication createAuthentication(String token) { - if (isExpired(token, jwtProperties.secret())) { - return new ExpiredTokenAuthentication(token); - } - return new SiteUserAuthentication(token); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java b/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java deleted file mode 100644 index 01b065a19..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.solidconnection.custom.security.provider; - - -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; -import com.example.solidconnection.custom.security.authentication.JwtAuthentication; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.stereotype.Component; - -import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; - -// todo: 사용되지 않음, 다른 PR에서 삭제하고 더 효율적인 구조를 고민해봐야 함 -@Component -@RequiredArgsConstructor -public class ExpiredTokenAuthenticationProvider implements AuthenticationProvider { - - private final JwtProperties jwtProperties; - - @Override - public Authentication authenticate(Authentication auth) throws AuthenticationException { - JwtAuthentication jwtAuth = (JwtAuthentication) auth; - String token = jwtAuth.getToken(); - String subject = parseSubjectIgnoringExpiration(token, jwtProperties.secret()); - - return new ExpiredTokenAuthentication(token, subject); - } - - @Override - public boolean supports(Class authentication) { - return ExpiredTokenAuthentication.class.isAssignableFrom(authentication); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java b/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java deleted file mode 100644 index 25f211710..000000000 --- a/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.solidconnection.custom.security.provider; - -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; -import com.example.solidconnection.custom.security.userdetails.SiteUserDetailsService; -import com.example.solidconnection.custom.security.authentication.JwtAuthentication; -import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.stereotype.Component; - -import static com.example.solidconnection.util.JwtUtils.parseSubject; - -@Component -@RequiredArgsConstructor -public class SiteUserAuthenticationProvider implements AuthenticationProvider { - - private final JwtProperties jwtProperties; - private final SiteUserDetailsService siteUserDetailsService; - - @Override - public Authentication authenticate(Authentication auth) throws AuthenticationException { - JwtAuthentication jwtAuth = (JwtAuthentication) auth; - String token = jwtAuth.getToken(); - - String username = parseSubject(token, jwtProperties.secret()); - SiteUserDetails userDetails = (SiteUserDetails) siteUserDetailsService.loadUserByUsername(username); - return new SiteUserAuthentication(token, userDetails); - } - - @Override - public boolean supports(Class authentication) { - return SiteUserAuthentication.class.isAssignableFrom(authentication); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java b/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java deleted file mode 100644 index 6ac9fe1c2..000000000 --- a/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.example.solidconnection.custom.validation.validator; - -import com.example.solidconnection.application.dto.UniversityChoiceRequest; -import com.example.solidconnection.custom.validation.annotation.ValidUniversityChoice; -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Stream; - -import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_UNIVERSITY_CHOICE; -import static com.example.solidconnection.custom.exception.ErrorCode.FIRST_CHOICE_REQUIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; - -public class ValidUniversityChoiceValidator implements ConstraintValidator { - - @Override - public boolean isValid(UniversityChoiceRequest request, ConstraintValidatorContext context) { - context.disableDefaultConstraintViolation(); - - if (isFirstChoiceNotSelected(request)) { - context.buildConstraintViolationWithTemplate(FIRST_CHOICE_REQUIRED.getMessage()) - .addConstraintViolation(); - return false; - } - - if (isThirdChoiceWithoutSecond(request)) { - context.buildConstraintViolationWithTemplate(THIRD_CHOICE_REQUIRES_SECOND.getMessage()) - .addConstraintViolation(); - return false; - } - - if (isDuplicate(request)) { - context.buildConstraintViolationWithTemplate(DUPLICATE_UNIVERSITY_CHOICE.getMessage()) - .addConstraintViolation(); - return false; - } - - return true; - } - - private boolean isFirstChoiceNotSelected(UniversityChoiceRequest request) { - return request.firstChoiceUniversityId() == null; - } - - private boolean isThirdChoiceWithoutSecond(UniversityChoiceRequest request) { - return request.thirdChoiceUniversityId() != null && request.secondChoiceUniversityId() == null; - } - - private boolean isDuplicate(UniversityChoiceRequest request) { - Set uniqueIds = new HashSet<>(); - return Stream.of( - request.firstChoiceUniversityId(), - request.secondChoiceUniversityId(), - request.thirdChoiceUniversityId() - ) - .filter(Objects::nonNull) - .anyMatch(id -> !uniqueIds.add(id)); - } -} diff --git a/src/main/java/com/example/solidconnection/entity/InterestedCountry.java b/src/main/java/com/example/solidconnection/entity/InterestedCountry.java deleted file mode 100644 index 8b8b4e735..000000000 --- a/src/main/java/com/example/solidconnection/entity/InterestedCountry.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.solidconnection.entity; - -import com.example.solidconnection.siteuser.domain.SiteUser; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -public class InterestedCountry { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - private SiteUser siteUser; - - @ManyToOne - private Country country; - - public InterestedCountry(SiteUser siteUser, Country country) { - this.siteUser = siteUser; - this.country = country; - } -} diff --git a/src/main/java/com/example/solidconnection/entity/InterestedRegion.java b/src/main/java/com/example/solidconnection/entity/InterestedRegion.java deleted file mode 100644 index 7ec8fa50c..000000000 --- a/src/main/java/com/example/solidconnection/entity/InterestedRegion.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.solidconnection.entity; - -import com.example.solidconnection.siteuser.domain.SiteUser; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -public class InterestedRegion { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - private SiteUser siteUser; - - @ManyToOne - private Region region; - - public InterestedRegion(SiteUser siteUser, Region region) { - this.siteUser = siteUser; - this.region = region; - } -} diff --git a/src/main/java/com/example/solidconnection/entity/Country.java b/src/main/java/com/example/solidconnection/location/country/domain/Country.java similarity index 70% rename from src/main/java/com/example/solidconnection/entity/Country.java rename to src/main/java/com/example/solidconnection/location/country/domain/Country.java index 0a5d974d7..f9a487eda 100644 --- a/src/main/java/com/example/solidconnection/entity/Country.java +++ b/src/main/java/com/example/solidconnection/location/country/domain/Country.java @@ -1,9 +1,8 @@ -package com.example.solidconnection.entity; +package com.example.solidconnection.location.country.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -22,12 +21,12 @@ public class Country { @Column(nullable = false, length = 100) private String koreanName; - @ManyToOne - private Region region; + @Column(name = "region_code") + private String regionCode; - public Country(String code, String koreanName, Region region) { + public Country(String code, String koreanName, String regionCode) { this.code = code; this.koreanName = koreanName; - this.region = region; + this.regionCode = regionCode; } } diff --git a/src/main/java/com/example/solidconnection/location/country/domain/InterestedCountry.java b/src/main/java/com/example/solidconnection/location/country/domain/InterestedCountry.java new file mode 100644 index 000000000..b1b01c798 --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/country/domain/InterestedCountry.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.location.country.domain; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_interested_country_site_user_id_country_code", + columnNames = {"site_user_id", "country_code"} + ) +}) +public class InterestedCountry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "site_user_id") + private long siteUserId; + + @Column(name = "country_code") + private String countryCode; + + public InterestedCountry(SiteUser siteUser, Country country) { + this.siteUserId = siteUser.getId(); + this.countryCode = country.getCode(); + } +} diff --git a/src/main/java/com/example/solidconnection/location/country/repository/CountryRepository.java b/src/main/java/com/example/solidconnection/location/country/repository/CountryRepository.java new file mode 100644 index 000000000..8b997a2de --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/country/repository/CountryRepository.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.location.country.repository; + +import com.example.solidconnection.location.country.domain.Country; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CountryRepository extends JpaRepository { + + List findAllByKoreanNameIn(List koreanNames); + + @Query(""" + SELECT DISTINCT c.koreanName + FROM Country c + WHERE c.code IN ( + SELECT ic.countryCode + FROM InterestedCountry ic + WHERE ic.siteUserId = :siteUserId + ) + """) + List findKoreanNamesBySiteUserId(@Param("siteUserId") long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/location/country/repository/InterestedCountryRepository.java b/src/main/java/com/example/solidconnection/location/country/repository/InterestedCountryRepository.java new file mode 100644 index 000000000..b7e12a285 --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/country/repository/InterestedCountryRepository.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.location.country.repository; + +import com.example.solidconnection.location.country.domain.InterestedCountry; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterestedCountryRepository extends JpaRepository { + + List findAllBySiteUserId(long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/location/country/service/InterestedCountryService.java b/src/main/java/com/example/solidconnection/location/country/service/InterestedCountryService.java new file mode 100644 index 000000000..9e2fa4060 --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/country/service/InterestedCountryService.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.location.country.service; + +import com.example.solidconnection.location.country.domain.InterestedCountry; +import com.example.solidconnection.location.country.repository.CountryRepository; +import com.example.solidconnection.location.country.repository.InterestedCountryRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class InterestedCountryService { + + private final CountryRepository countryRepository; + private final InterestedCountryRepository interestedCountryRepository; + + @Transactional + public void saveInterestedCountry(SiteUser siteUser, List koreanNames) { + List interestedCountries = countryRepository.findAllByKoreanNameIn(koreanNames) + .stream() + .map(country -> new InterestedCountry(siteUser, country)) + .toList(); + interestedCountryRepository.saveAll(interestedCountries); + } + + @Transactional + public void updateInterestedCountry(SiteUser siteUser, List koreanNames) { + interestedCountryRepository.deleteAllBySiteUserId(siteUser.getId()); + + List interestedCountries = countryRepository.findAllByKoreanNameIn(koreanNames) + .stream() + .map(country -> new InterestedCountry(siteUser, country)) + .toList(); + interestedCountryRepository.saveAll(interestedCountries); + } +} diff --git a/src/main/java/com/example/solidconnection/location/region/domain/InterestedRegion.java b/src/main/java/com/example/solidconnection/location/region/domain/InterestedRegion.java new file mode 100644 index 000000000..e08287fe9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/region/domain/InterestedRegion.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.location.region.domain; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_interested_region_site_user_id_region_code", + columnNames = {"site_user_id", "region_code"} + ) +}) +public class InterestedRegion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "site_user_id") + private long siteUserId; + + @Column(name = "region_code") + private String regionCode; + + public InterestedRegion(SiteUser siteUser, Region region) { + this.siteUserId = siteUser.getId(); + this.regionCode = region.getCode(); + } +} diff --git a/src/main/java/com/example/solidconnection/entity/Region.java b/src/main/java/com/example/solidconnection/location/region/domain/Region.java similarity index 91% rename from src/main/java/com/example/solidconnection/entity/Region.java rename to src/main/java/com/example/solidconnection/location/region/domain/Region.java index 6bd64c5cc..cb0d5ab7a 100644 --- a/src/main/java/com/example/solidconnection/entity/Region.java +++ b/src/main/java/com/example/solidconnection/location/region/domain/Region.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.entity; +package com.example.solidconnection.location.region.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/com/example/solidconnection/location/region/repository/InterestedRegionRepository.java b/src/main/java/com/example/solidconnection/location/region/repository/InterestedRegionRepository.java new file mode 100644 index 000000000..367eb2164 --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/region/repository/InterestedRegionRepository.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.location.region.repository; + +import com.example.solidconnection.location.region.domain.InterestedRegion; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterestedRegionRepository extends JpaRepository { + + List findAllBySiteUserId(long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java b/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java new file mode 100644 index 000000000..dea93fb34 --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.location.region.repository; + +import com.example.solidconnection.location.region.domain.Region; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepository extends JpaRepository { + + List findAllByKoreanNameIn(List koreanNames); + + Optional findByKoreanName(String koreanName); +} diff --git a/src/main/java/com/example/solidconnection/location/region/service/InterestedRegionService.java b/src/main/java/com/example/solidconnection/location/region/service/InterestedRegionService.java new file mode 100644 index 000000000..2c8c592cd --- /dev/null +++ b/src/main/java/com/example/solidconnection/location/region/service/InterestedRegionService.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.location.region.service; + +import com.example.solidconnection.location.region.domain.InterestedRegion; +import com.example.solidconnection.location.region.repository.InterestedRegionRepository; +import com.example.solidconnection.location.region.repository.RegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class InterestedRegionService { + + private final RegionRepository regionRepository; + private final InterestedRegionRepository interestedRegionRepository; + + @Transactional + public void saveInterestedRegion(SiteUser siteUser, List koreanNames) { + List interestedRegions = regionRepository.findAllByKoreanNameIn(koreanNames) + .stream() + .map(region -> new InterestedRegion(siteUser, region)) + .toList(); + interestedRegionRepository.saveAll(interestedRegions); + } + + @Transactional + public void updateInterestedRegion(SiteUser siteUser, List koreanNames) { + interestedRegionRepository.deleteAllBySiteUserId(siteUser.getId()); + + List interestedRegions = regionRepository.findAllByKoreanNameIn(koreanNames) + .stream() + .map(region -> new InterestedRegion(siteUser, region)) + .toList(); + interestedRegionRepository.saveAll(interestedRegions); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/controller/MentorController.java b/src/main/java/com/example/solidconnection/mentor/controller/MentorController.java new file mode 100644 index 000000000..0ce04e1f7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/controller/MentorController.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.mentor.controller; + +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.mentor.dto.MentorDetailResponse; +import com.example.solidconnection.mentor.dto.MentorPreviewResponse; +import com.example.solidconnection.mentor.service.MentorQueryService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.data.web.SortDefault.SortDefaults; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/mentors") +@RestController +public class MentorController { + + private final MentorQueryService mentorQueryService; + + @GetMapping("/{mentor-id}") + public ResponseEntity getMentorDetails( + @AuthorizedUser long siteUserId, + @PathVariable("mentor-id") Long mentorId + ) { + MentorDetailResponse response = mentorQueryService.getMentorDetails(mentorId, siteUserId); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getMentorPreviews( + @AuthorizedUser long siteUserId, + @RequestParam("region") String region, + + @PageableDefault(size = 3) + @SortDefaults({ + @SortDefault(sort = "menteeCount", direction = Sort.Direction.DESC), + @SortDefault(sort = "id", direction = Sort.Direction.ASC) + }) + Pageable pageable + ) { + SliceResponse response = mentorQueryService.getMentorPreviews(region, siteUserId, pageable); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java b/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java new file mode 100644 index 000000000..dd9289a3e --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.mentor.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.mentor.dto.MentorMyPageResponse; +import com.example.solidconnection.mentor.dto.MentorMyPageUpdateRequest; +import com.example.solidconnection.mentor.service.MentorMyPageService; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RequiredArgsConstructor +@RequestMapping("/mentor/my") +@RestController +public class MentorMyPageController { + + private final MentorMyPageService mentorMyPageService; + + @RequireRoleAccess(roles = Role.MENTOR) + @GetMapping + public ResponseEntity getMentorMyPage( + @AuthorizedUser long siteUserId + ) { + MentorMyPageResponse mentorMyPageResponse = mentorMyPageService.getMentorMyPage(siteUserId); + return ResponseEntity.ok(mentorMyPageResponse); + } + + @RequireRoleAccess(roles = Role.MENTOR) + @PutMapping + public ResponseEntity updateMentorMyPage( + @AuthorizedUser long siteUserId, + @Valid @RequestBody MentorMyPageUpdateRequest mentorMyPageUpdateRequest + ) { + mentorMyPageService.updateMentorMyPage(siteUserId, mentorMyPageUpdateRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java b/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java new file mode 100644 index 000000000..28683cd37 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java @@ -0,0 +1,76 @@ +package com.example.solidconnection.mentor.controller; + +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.mentor.dto.CheckMentoringRequest; +import com.example.solidconnection.mentor.dto.CheckedMentoringsResponse; +import com.example.solidconnection.mentor.dto.MentoringApplyRequest; +import com.example.solidconnection.mentor.dto.MentoringApplyResponse; +import com.example.solidconnection.mentor.dto.MentoringForMenteeResponse; +import com.example.solidconnection.mentor.service.MentoringCheckService; +import com.example.solidconnection.mentor.service.MentoringCommandService; +import com.example.solidconnection.mentor.service.MentoringQueryService; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.data.web.SortDefault.SortDefaults; +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.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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/mentee/mentorings") +public class MentoringForMenteeController { + + private final MentoringCommandService mentoringCommandService; + private final MentoringQueryService mentoringQueryService; + private final MentoringCheckService mentoringCheckService; + + @RequireRoleAccess(roles = Role.MENTEE) + @PostMapping + public ResponseEntity applyMentoring( + @AuthorizedUser long siteUserId, + @Valid @RequestBody MentoringApplyRequest mentoringApplyRequest + ) { + MentoringApplyResponse response = mentoringCommandService.applyMentoring(siteUserId, mentoringApplyRequest); + return ResponseEntity.ok(response); + } + + @RequireRoleAccess(roles = Role.MENTEE) + @GetMapping + public ResponseEntity> getMentorings( + @AuthorizedUser long siteUserId, + @RequestParam("verify-status") VerifyStatus verifyStatus, + @PageableDefault(size = 3) + @SortDefaults({ + @SortDefault(sort = "createdAt", direction = Sort.Direction.DESC), + @SortDefault(sort = "id", direction = Sort.Direction.DESC) + }) + Pageable pageable + ) { + SliceResponse response = mentoringQueryService.getMentoringsForMentee(siteUserId, verifyStatus, pageable); + return ResponseEntity.ok(response); + } + + @RequireRoleAccess(roles = {Role.MENTEE}) + @PatchMapping("/check") + public ResponseEntity checkMentorings( + @AuthorizedUser long siteUserId, + @Valid @RequestBody CheckMentoringRequest checkMentoringRequest + ) { + CheckedMentoringsResponse response = mentoringCheckService.checkMentoringsForMentee(siteUserId, checkMentoringRequest); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMentorController.java b/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMentorController.java new file mode 100644 index 000000000..b9fb2f63e --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMentorController.java @@ -0,0 +1,84 @@ +package com.example.solidconnection.mentor.controller; + +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.mentor.dto.CheckMentoringRequest; +import com.example.solidconnection.mentor.dto.CheckedMentoringsResponse; +import com.example.solidconnection.mentor.dto.MentoringConfirmRequest; +import com.example.solidconnection.mentor.dto.MentoringConfirmResponse; +import com.example.solidconnection.mentor.dto.MentoringCountResponse; +import com.example.solidconnection.mentor.dto.MentoringForMentorResponse; +import com.example.solidconnection.mentor.service.MentoringCheckService; +import com.example.solidconnection.mentor.service.MentoringCommandService; +import com.example.solidconnection.mentor.service.MentoringQueryService; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.data.web.SortDefault.SortDefaults; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/mentor/mentorings") +public class MentoringForMentorController { + + private final MentoringCommandService mentoringCommandService; + private final MentoringQueryService mentoringQueryService; + private final MentoringCheckService mentoringCheckService; + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @GetMapping + public ResponseEntity> getMentorings( + @AuthorizedUser long siteUserId, + @PageableDefault(size = 3) + @SortDefaults({ + @SortDefault(sort = "createdAt", direction = Sort.Direction.DESC), + @SortDefault(sort = "id", direction = Sort.Direction.DESC) + }) + Pageable pageable + ) { + SliceResponse response = mentoringQueryService.getMentoringsForMentor(siteUserId, pageable); + return ResponseEntity.ok(response); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PatchMapping("/{mentoring-id}") + public ResponseEntity confirmMentoring( + @AuthorizedUser long siteUserId, + @PathVariable("mentoring-id") Long mentoringId, + @Valid @RequestBody MentoringConfirmRequest mentoringConfirmRequest + ) { + MentoringConfirmResponse response = mentoringCommandService.confirmMentoring(siteUserId, mentoringId, mentoringConfirmRequest); + return ResponseEntity.ok(response); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PatchMapping("/check") + public ResponseEntity checkMentoring( + @AuthorizedUser long siteUserId, + @RequestBody CheckMentoringRequest mentoringCheckRequest + ) { + CheckedMentoringsResponse response = mentoringCheckService.checkMentoringsForMentor(siteUserId, mentoringCheckRequest); + return ResponseEntity.ok(response); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @GetMapping("/check") + public ResponseEntity getUncheckedMentoringsCount( + @AuthorizedUser long siteUserId + ) { + MentoringCountResponse response = mentoringCheckService.getUncheckedMentoringCount(siteUserId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/domain/Channel.java b/src/main/java/com/example/solidconnection/mentor/domain/Channel.java new file mode 100644 index 000000000..10ed3e940 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/domain/Channel.java @@ -0,0 +1,63 @@ +package com.example.solidconnection.mentor.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_channel_mentor_id_sequence", + columnNames = {"mentor_id", "sequence"} + ) +}) +public class Channel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private int sequence; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChannelType type; + + @Column(nullable = false, length = 500) + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + private Mentor mentor; + + public Channel(int sequence, ChannelType type, String url) { + this.sequence = sequence; + this.type = type; + this.url = url; + } + + public void updateMentor(Mentor mentor) { + this.mentor = mentor; + } + + public void update(Channel channel) { + this.sequence = channel.sequence; + this.type = channel.type; + this.url = channel.url; + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/domain/ChannelType.java b/src/main/java/com/example/solidconnection/mentor/domain/ChannelType.java new file mode 100644 index 000000000..c12cf321f --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/domain/ChannelType.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.mentor.domain; + +public enum ChannelType { + + BLOG, + INSTAGRAM, + YOUTUBE, + BRUNCH, + ; +} diff --git a/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java b/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java new file mode 100644 index 000000000..c14475b7e --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java @@ -0,0 +1,84 @@ +package com.example.solidconnection.mentor.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mentor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private int menteeCount = 0; + + @Column + private boolean hasBadge = false; + + @Column(length = 1000, nullable = false) + private String introduction; + + @Column(length = 1000, nullable = false) + private String passTip; + + @Column + private long siteUserId; + + @Column + private long universityId; + + @Column(length = 50, nullable = false) + private String term; + + @BatchSize(size = 10) + @OrderBy("sequence ASC") + @OneToMany(mappedBy = "mentor", cascade = CascadeType.ALL, orphanRemoval = true) + private List channels = new ArrayList<>(); + + public void increaseMenteeCount() { + this.menteeCount++; + } + + public void updateIntroduction(String introduction) { + this.introduction = introduction; + } + + public void updatePassTip(String passTip) { + this.passTip = passTip; + } + + public void updateChannels(List channels) { + int newChannelSize = Math.max(channels.size(), this.channels.size()); + int originalChannelSize = this.channels.size(); + for (int i = 0; i < newChannelSize; i++) { + if (i < channels.size() && i < this.channels.size()) { // 기존 채널 수정 + Channel existing = this.channels.get(i); + Channel newChannel = channels.get(i); + existing.update(newChannel); + } else if (i < channels.size()) { // 채널 갯수 늘어남 - 새로운 채널 추가 + Channel newChannel = channels.get(i); + newChannel.updateMentor(this); + this.channels.add(newChannel); + } else if (i < originalChannelSize) { // 채널 갯수 줄어듦 - 기존 채널 삭제 + this.channels.remove(this.channels.size() - 1); + } + } + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java b/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java new file mode 100644 index 000000000..ead13132b --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.mentor.domain; + +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.MICROS; + +import com.example.solidconnection.common.VerifyStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import java.time.ZonedDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicInsert; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@EntityListeners(AuditingEntityListener.class) +@DynamicInsert +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mentoring { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private ZonedDateTime createdAt; + + @Column + private ZonedDateTime confirmedAt; + + @Column + private ZonedDateTime checkedAtByMentor; + + @Column + private ZonedDateTime checkedAtByMentee; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private VerifyStatus verifyStatus = VerifyStatus.PENDING; + + @Column + private long mentorId; + + @Column + private long menteeId; + + public Mentoring(long mentorId, long menteeId, VerifyStatus verifyStatus) { + this.mentorId = mentorId; + this.menteeId = menteeId; + this.verifyStatus = verifyStatus; + } + + @PrePersist + public void onPrePersist() { + this.createdAt = ZonedDateTime.now(UTC).truncatedTo(MICROS); // 나노초 6자리 까지만 저장 + } + + public void confirm(VerifyStatus status) { + this.verifyStatus = status; + this.confirmedAt = ZonedDateTime.now(UTC).truncatedTo(MICROS); + + if (this.checkedAtByMentor == null) { + this.checkedAtByMentor = this.confirmedAt; + } + if (this.checkedAtByMentee != null) { + this.checkedAtByMentee = null; + } + } + + public void checkByMentor() { + this.checkedAtByMentor = ZonedDateTime.now(UTC).truncatedTo(MICROS); + } + + public void checkByMentee() { + this.checkedAtByMentee = ZonedDateTime.now(UTC).truncatedTo(MICROS); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/ChannelRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/ChannelRequest.java new file mode 100644 index 000000000..11c401be8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/ChannelRequest.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.ChannelType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.URL; + +public record ChannelRequest( + @NotNull(message = "채널 종류를 입력해주세요.") + ChannelType type, + + @NotBlank(message = "채널 URL을 입력해주세요.") + @URL + String url +) { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/ChannelResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/ChannelResponse.java new file mode 100644 index 000000000..cc6de7c71 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/ChannelResponse.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Channel; +import com.example.solidconnection.mentor.domain.ChannelType; + +public record ChannelResponse( + ChannelType type, + String url +) { + + public static ChannelResponse from(Channel channel) { + return new ChannelResponse( + channel.getType(), + channel.getUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/CheckMentoringRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/CheckMentoringRequest.java new file mode 100644 index 000000000..ebd3bacf1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/CheckMentoringRequest.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.mentor.dto; + +import java.util.List; + +public record CheckMentoringRequest( + List checkedMentoringIds +) { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/CheckedMentoringsResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/CheckedMentoringsResponse.java new file mode 100644 index 000000000..2b286543a --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/CheckedMentoringsResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Mentoring; +import java.util.List; + +public record CheckedMentoringsResponse( + List checkedMentoringIds +) { + + public static CheckedMentoringsResponse from(List mentorings) { + return new CheckedMentoringsResponse( + mentorings.stream().map(Mentoring::getId).toList() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentorDetailResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentorDetailResponse.java new file mode 100644 index 000000000..6b5f04b0c --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentorDetailResponse.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.University; +import java.util.List; + +public record MentorDetailResponse( + long id, + String nickname, + String profileImageUrl, + String country, + String universityName, + String term, + int menteeCount, + boolean hasBadge, + String introduction, + List channels, + String passTip, + boolean isApplied +) { + + public static MentorDetailResponse of(Mentor mentor, SiteUser mentorUser, + University university, boolean isApplied) { + return new MentorDetailResponse( + mentor.getId(), + mentorUser.getNickname(), + mentorUser.getProfileImageUrl(), + university.getCountry().getKoreanName(), + university.getKoreanName(), + mentor.getTerm(), + mentor.getMenteeCount(), + mentor.isHasBadge(), + mentor.getIntroduction(), + mentor.getChannels().stream().map(ChannelResponse::from).toList(), + mentor.getPassTip(), + isApplied + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageResponse.java new file mode 100644 index 000000000..2ae1acba7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageResponse.java @@ -0,0 +1,39 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.University; +import java.util.List; + +public record MentorMyPageResponse( + long id, + String profileImageUrl, + String nickname, + String country, + String universityName, + String term, + int menteeCount, + boolean hasBadge, + String introduction, + String passTip, + List channels +) { + + public static MentorMyPageResponse of(Mentor mentor, SiteUser siteUser, University university) { + return new MentorMyPageResponse( + mentor.getId(), + siteUser.getProfileImageUrl(), + siteUser.getNickname(), + university.getCountry().getKoreanName(), + university.getKoreanName(), + mentor.getTerm(), + mentor.getMenteeCount(), + mentor.isHasBadge(), + mentor.getIntroduction(), + mentor.getPassTip(), + mentor.getChannels().stream() + .map(ChannelResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageUpdateRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageUpdateRequest.java new file mode 100644 index 000000000..646fc6e78 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageUpdateRequest.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.mentor.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +public record MentorMyPageUpdateRequest( + @NotBlank(message = "자기소개를 입력해주세요.") + String introduction, + + @NotBlank(message = "합격 레시피를 입력해주세요.") + String passTip, + + @Valid + List channels +) { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentorPreviewResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentorPreviewResponse.java new file mode 100644 index 000000000..c6678fb53 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentorPreviewResponse.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.University; +import java.util.List; + +public record MentorPreviewResponse( + long id, + String nickname, + String profileImageUrl, + String country, + String universityName, + String term, + int menteeCount, + boolean hasBadge, + String introduction, + List channels, + boolean isApplied +) { + + public static MentorPreviewResponse of(Mentor mentor, SiteUser mentorUser, + University university, boolean isApplied) { + return new MentorPreviewResponse( + mentor.getId(), + mentorUser.getNickname(), + mentorUser.getProfileImageUrl(), + university.getCountry().getKoreanName(), + university.getKoreanName(), + mentor.getTerm(), + mentor.getMenteeCount(), + mentor.isHasBadge(), + mentor.getIntroduction(), + mentor.getChannels().stream().map(ChannelResponse::from).toList(), + isApplied + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringApplyRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringApplyRequest.java new file mode 100644 index 000000000..8ff6b6611 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringApplyRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.mentor.dto; + +import jakarta.validation.constraints.NotNull; + +public record MentoringApplyRequest( + @NotNull(message = "멘토 id를 입력해주세요.") + Long mentorId +) { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringApplyResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringApplyResponse.java new file mode 100644 index 000000000..d77523aa7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringApplyResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Mentoring; + +public record MentoringApplyResponse( + long mentoringId +) { + + public static MentoringApplyResponse from(Mentoring mentoring) { + return new MentoringApplyResponse(mentoring.getId()); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringCheckResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringCheckResponse.java new file mode 100644 index 000000000..581ddd141 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringCheckResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.mentor.dto; + +public record MentoringCheckResponse( + long mentoringId +) { + + public static MentoringCheckResponse from(long mentoringId) { + return new MentoringCheckResponse(mentoringId); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringConfirmRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringConfirmRequest.java new file mode 100644 index 000000000..cb284d4c6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringConfirmRequest.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.common.VerifyStatus; +import jakarta.validation.constraints.NotNull; + +public record MentoringConfirmRequest( + @NotNull(message = "승인 상태를 설정해주세요.") + VerifyStatus status +) { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringConfirmResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringConfirmResponse.java new file mode 100644 index 000000000..1cf8f03ff --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringConfirmResponse.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.mentor.dto; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +import com.example.solidconnection.mentor.domain.Mentoring; +import com.fasterxml.jackson.annotation.JsonInclude; + +public record MentoringConfirmResponse( + long mentoringId, + + @JsonInclude(NON_NULL) + Long chatRoomId +) { + + public static MentoringConfirmResponse from(Mentoring mentoring, Long chatRoomId) { + return new MentoringConfirmResponse(mentoring.getId(), chatRoomId); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringCountResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringCountResponse.java new file mode 100644 index 000000000..428b0b7f3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringCountResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.mentor.dto; + +public record MentoringCountResponse( + int uncheckedCount +) { + + public static MentoringCountResponse from(int uncheckedCount) { + return new MentoringCountResponse(uncheckedCount); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMenteeResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMenteeResponse.java new file mode 100644 index 000000000..65e1e0120 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMenteeResponse.java @@ -0,0 +1,26 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.siteuser.domain.SiteUser; +import java.time.ZonedDateTime; + +public record MentoringForMenteeResponse( + long mentoringId, + String profileImageUrl, + String nickname, + boolean isChecked, + ZonedDateTime createdAt, + Long chatRoomId +) { + + public static MentoringForMenteeResponse of(Mentoring mentoring, SiteUser partner, Long chatRoomId) { + return new MentoringForMenteeResponse( + mentoring.getId(), + partner.getProfileImageUrl(), + partner.getNickname(), + mentoring.getCheckedAtByMentee() != null, + mentoring.getCreatedAt(), + chatRoomId + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java new file mode 100644 index 000000000..8a41fba84 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.mentor.dto; + +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.siteuser.domain.SiteUser; +import java.time.ZonedDateTime; + +public record MentoringForMentorResponse( + long mentoringId, + String profileImageUrl, + String nickname, + boolean isChecked, + VerifyStatus verifyStatus, + ZonedDateTime createdAt +) { + + public static MentoringForMentorResponse of(Mentoring mentoring, SiteUser partner) { + return new MentoringForMentorResponse( + mentoring.getId(), + partner.getProfileImageUrl(), + partner.getNickname(), + mentoring.getCheckedAtByMentor() != null, + mentoring.getVerifyStatus(), + mentoring.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/repository/ChannelRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/ChannelRepository.java new file mode 100644 index 000000000..a8c607389 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/repository/ChannelRepository.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.mentor.repository; + +import com.example.solidconnection.mentor.domain.Channel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChannelRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorBatchQueryRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorBatchQueryRepository.java new file mode 100644 index 000000000..0b01f3871 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorBatchQueryRepository.java @@ -0,0 +1,76 @@ +package com.example.solidconnection.mentor.repository; + +import static com.example.solidconnection.common.exception.ErrorCode.DATA_INTEGRITY_VIOLATION; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.UniversityRepository; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MentorBatchQueryRepository { // 연관관계가 설정되지 않은 엔티티들을 N+1 없이 하나의 쿼리로 조회 + + private final SiteUserRepository siteUserRepository; + private final MentoringRepository mentoringRepository; + private final UniversityRepository universityRepository; + + public Map getMentorIdToSiteUserMap(List mentors) { + List mentorUserIds = mentors.stream().map(Mentor::getSiteUserId).toList(); + List mentorUsers = siteUserRepository.findAllById(mentorUserIds); + Map mentorUserIdToSiteUserMap = mentorUsers.stream() + .collect(Collectors.toMap(SiteUser::getId, Function.identity())); + + return mentors.stream().collect(Collectors.toMap( + Mentor::getId, + mentor -> { + SiteUser mentorUser = mentorUserIdToSiteUserMap.get(mentor.getSiteUserId()); + if (mentorUser == null) { // site_user.id == mentor.site_user_id 에 해당하는게 없으면 정합성 문제가 발생한 것 + throw new CustomException(DATA_INTEGRITY_VIOLATION, "mentor에 해당하는 siteUser 존재하지 않음"); + } + return mentorUser; + } + )); + } + + public Map getMentorIdToUniversityMap(List mentors) { + List universityIds = mentors.stream().map(Mentor::getUniversityId).distinct().toList(); + List universities = universityRepository.findAllById(universityIds); + Map universityIdToUniversityMap = universities.stream() + .collect(Collectors.toMap(University::getId, Function.identity())); + + return mentors.stream().collect(Collectors.toMap( + Mentor::getId, + mentor -> { + University university = universityIdToUniversityMap.get(mentor.getUniversityId()); + if (university == null) { // mentor.university_id에 해당하는 대학이 없으면 정합성 문제가 발생한 것 + throw new CustomException(DATA_INTEGRITY_VIOLATION, "mentor.university_id 에 해당하는 university 존재하지 않음"); + } + return university; + } + )); + } + + public Map getMentorIdToIsApplied(List mentors, long currentUserId) { + List mentorIds = mentors.stream().map(Mentor::getId).toList(); + List appliedMentorings = mentoringRepository.findAllByMentorIdInAndMenteeId(mentorIds, currentUserId); + Set appliedMentorIds = appliedMentorings.stream() + .map(Mentoring::getMentorId) + .collect(Collectors.toSet()); + + return mentors.stream().collect(Collectors.toMap( + Mentor::getId, + mentor -> appliedMentorIds.contains(mentor.getId()) + )); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java new file mode 100644 index 000000000..430602f1e --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java @@ -0,0 +1,26 @@ +package com.example.solidconnection.mentor.repository; + +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.mentor.domain.Mentor; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MentorRepository extends JpaRepository { + + boolean existsBySiteUserId(long siteUserId); + + Optional findBySiteUserId(long siteUserId); + + Slice findAllBy(Pageable pageable); + + @Query(""" + SELECT m FROM Mentor m + JOIN University u ON m.universityId = u.id + WHERE u.region = :region + """) + Slice findAllByRegion(@Param("region") Region region, Pageable pageable); +} diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java new file mode 100644 index 000000000..16caf3318 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.mentor.repository; + +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.mentor.domain.Mentoring; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MentoringRepository extends JpaRepository { + + int countByMentorIdAndCheckedAtByMentorIsNull(long mentorId); + + boolean existsByMentorIdAndMenteeId(long mentorId, long menteeId); + + Slice findAllByMentorId(long mentorId, Pageable pageable); + + @Query(""" + SELECT m FROM Mentoring m + WHERE m.menteeId = :menteeId AND m.verifyStatus = :verifyStatus + """) + Slice findAllByMenteeIdAndVerifyStatus(@Param("menteeId") long menteeId, @Param("verifyStatus") VerifyStatus verifyStatus, Pageable pageable); + + @Query(""" + SELECT m FROM Mentoring m + WHERE m.mentorId IN :mentorIds AND m.menteeId = :menteeId + """) + List findAllByMentorIdInAndMenteeId(@Param("mentorIds") List mentorIds, @Param("menteeId") long menteeId); +} diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java b/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java new file mode 100644 index 000000000..18e63a6fb --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java @@ -0,0 +1,72 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.common.exception.ErrorCode.CHANNEL_REGISTRATION_LIMIT_EXCEEDED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.mentor.domain.Channel; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.dto.ChannelRequest; +import com.example.solidconnection.mentor.dto.MentorMyPageResponse; +import com.example.solidconnection.mentor.dto.MentorMyPageUpdateRequest; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.UniversityRepository; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class MentorMyPageService { + + private static final int CHANNEL_REGISTRATION_LIMIT = 4; + private static final int CHANNEL_SEQUENCE_START_NUMBER = 1; + + private final MentorRepository mentorRepository; + private final SiteUserRepository siteUserRepository; + private final UniversityRepository universityRepository; + + @Transactional(readOnly = true) + public MentorMyPageResponse getMentorMyPage(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + Mentor mentor = mentorRepository.findBySiteUserId(siteUser.getId()) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + University university = universityRepository.findById(mentor.getUniversityId()) + .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + return MentorMyPageResponse.of(mentor, siteUser, university); + } + + @Transactional + public void updateMentorMyPage(long siteUserId, MentorMyPageUpdateRequest request) { + validateChannelRegistrationLimit(request.channels()); + Mentor mentor = mentorRepository.findBySiteUserId(siteUserId) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + + mentor.updateIntroduction(request.introduction()); + mentor.updatePassTip(request.passTip()); + updateChannel(request.channels(), mentor); + } + + private void validateChannelRegistrationLimit(List channelRequests) { + if (channelRequests.size() > CHANNEL_REGISTRATION_LIMIT) { + throw new CustomException(CHANNEL_REGISTRATION_LIMIT_EXCEEDED); + } + } + + private void updateChannel(List channelRequests, Mentor mentor) { + int sequence = CHANNEL_SEQUENCE_START_NUMBER; + List newChannels = new ArrayList<>(); + for (ChannelRequest request : channelRequests) { + newChannels.add(new Channel(sequence++, request.type(), request.url())); + } + mentor.updateChannels(newChannels); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentorQueryService.java b/src/main/java/com/example/solidconnection/mentor/service/MentorQueryService.java new file mode 100644 index 000000000..16b7172d1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/service/MentorQueryService.java @@ -0,0 +1,87 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; + +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.repository.RegionRepository; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.dto.MentorDetailResponse; +import com.example.solidconnection.mentor.dto.MentorPreviewResponse; +import com.example.solidconnection.mentor.repository.MentorBatchQueryRepository; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.UniversityRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class MentorQueryService { + + private final MentorRepository mentorRepository; + private final MentoringRepository mentoringRepository; + private final SiteUserRepository siteUserRepository; + private final MentorBatchQueryRepository mentorBatchQueryRepository; + private final UniversityRepository universityRepository; + private final RegionRepository regionRepository; + + @Transactional(readOnly = true) + public MentorDetailResponse getMentorDetails(long mentorId, long currentUserId) { + Mentor mentor = mentorRepository.findById(mentorId) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + University university = universityRepository.findById(mentor.getUniversityId()) + .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + SiteUser mentorUser = siteUserRepository.findById(mentor.getSiteUserId()) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + boolean isApplied = mentoringRepository.existsByMentorIdAndMenteeId(mentorId, currentUserId); + + return MentorDetailResponse.of(mentor, mentorUser, university, isApplied); + } + + @Transactional(readOnly = true) + public SliceResponse getMentorPreviews(String regionKoreanName, long currentUserId, Pageable pageable) { + Slice mentorSlice = filterMentorsByRegion(regionKoreanName, pageable); + List mentors = mentorSlice.toList(); + List content = buildMentorPreviewsWithBatchQuery(mentors, currentUserId); + + return SliceResponse.of(content, mentorSlice); + } + + private Slice filterMentorsByRegion(String regionKoreanName, Pageable pageable) { + if (regionKoreanName == null || regionKoreanName.isEmpty()) { + return mentorRepository.findAll(pageable); + } + Region region = regionRepository.findByKoreanName(regionKoreanName) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND_BY_KOREAN_NAME)); + return mentorRepository.findAllByRegion(region, pageable); + } + + private List buildMentorPreviewsWithBatchQuery(List mentors, long currentUserId) { + Map mentorIdToSiteUser = mentorBatchQueryRepository.getMentorIdToSiteUserMap(mentors); + Map mentorIdToUniversity = mentorBatchQueryRepository.getMentorIdToUniversityMap(mentors); + Map mentorIdToIsApplied = mentorBatchQueryRepository.getMentorIdToIsApplied(mentors, currentUserId); + + List mentorPreviews = new ArrayList<>(); + for (Mentor mentor : mentors) { + SiteUser mentorUser = mentorIdToSiteUser.get(mentor.getId()); + University university = mentorIdToUniversity.get(mentor.getId()); + boolean isApplied = mentorIdToIsApplied.get(mentor.getId()); + MentorPreviewResponse response = MentorPreviewResponse.of(mentor, mentorUser, university, isApplied); + mentorPreviews.add(response); + } + return mentorPreviews; + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentoringCheckService.java b/src/main/java/com/example/solidconnection/mentor/service/MentoringCheckService.java new file mode 100644 index 000000000..5c73ad00b --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/service/MentoringCheckService.java @@ -0,0 +1,74 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.UNAUTHORIZED_MENTORING; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.CheckMentoringRequest; +import com.example.solidconnection.mentor.dto.CheckedMentoringsResponse; +import com.example.solidconnection.mentor.dto.MentoringCountResponse; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MentoringCheckService { + + private final MentorRepository mentorRepository; + private final MentoringRepository mentoringRepository; + + @Transactional + public CheckedMentoringsResponse checkMentoringsForMentor(long mentorUserId, CheckMentoringRequest checkMentoringRequest) { + Mentor mentor = mentorRepository.findBySiteUserId(mentorUserId) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + List mentorings = mentoringRepository.findAllById(checkMentoringRequest.checkedMentoringIds()); + List actualMentorIds = mentorings.stream() + .map(Mentoring::getMentorId) + .distinct() + .toList(); + + mentorings.forEach(Mentoring::checkByMentor); + validateMentoringsOwnership(actualMentorIds, mentor.getId()); + + return CheckedMentoringsResponse.from(mentorings); + } + + @Transactional + public CheckedMentoringsResponse checkMentoringsForMentee(long menteeUserId, CheckMentoringRequest checkMentoringRequest) { + List mentorings = mentoringRepository.findAllById(checkMentoringRequest.checkedMentoringIds()); + List actualMenteeIds = mentorings.stream() + .map(Mentoring::getMenteeId) + .distinct() + .toList(); + + validateMentoringsOwnership(actualMenteeIds, menteeUserId); + mentorings.forEach(Mentoring::checkByMentee); + + return CheckedMentoringsResponse.from(mentorings); + } + + private void validateMentoringsOwnership(List actualOwnerIds, long expectedOwnerId) { + actualOwnerIds.stream() + .filter(actualOwnerId -> actualOwnerId != expectedOwnerId) + .findFirst() + .ifPresent(ownerId -> { + throw new CustomException(UNAUTHORIZED_MENTORING); + }); + } + + @Transactional(readOnly = true) + public MentoringCountResponse getUncheckedMentoringCount(long siteUserId) { + Mentor mentor = mentorRepository.findBySiteUserId(siteUserId) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + + int count = mentoringRepository.countByMentorIdAndCheckedAtByMentorIsNull(mentor.getId()); + + return MentoringCountResponse.from(count); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java b/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java new file mode 100644 index 000000000..254323127 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java @@ -0,0 +1,72 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_ALREADY_CONFIRMED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.UNAUTHORIZED_MENTORING; + +import com.example.solidconnection.chat.service.ChatService; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.MentoringApplyRequest; +import com.example.solidconnection.mentor.dto.MentoringApplyResponse; +import com.example.solidconnection.mentor.dto.MentoringConfirmRequest; +import com.example.solidconnection.mentor.dto.MentoringConfirmResponse; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MentoringCommandService { + + private final MentoringRepository mentoringRepository; + private final MentorRepository mentorRepository; + private final ChatService chatService; + + @Transactional + public MentoringApplyResponse applyMentoring(long siteUserId, MentoringApplyRequest mentoringApplyRequest) { + Mentoring mentoring = new Mentoring(mentoringApplyRequest.mentorId(), siteUserId, VerifyStatus.PENDING); + + return MentoringApplyResponse.from(mentoringRepository.save(mentoring)); + } + + @Transactional + public MentoringConfirmResponse confirmMentoring(long siteUserId, long mentoringId, MentoringConfirmRequest mentoringConfirmRequest) { + Mentoring mentoring = mentoringRepository.findById(mentoringId) + .orElseThrow(() -> new CustomException(MENTORING_NOT_FOUND)); + + Mentor mentor = mentorRepository.findBySiteUserId(siteUserId) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + + validateMentoringOwnership(mentor, mentoring); + validateMentoringNotConfirmed(mentoring); + + mentoring.confirm(mentoringConfirmRequest.status()); + + Long chatRoomId = null; + if (mentoringConfirmRequest.status() == VerifyStatus.APPROVED) { + mentor.increaseMenteeCount(); + chatRoomId = chatService.createMentoringChatRoom(mentoringId, mentor.getSiteUserId(), mentoring.getMenteeId()); + } + + return MentoringConfirmResponse.from(mentoring, chatRoomId); + } + + private void validateMentoringNotConfirmed(Mentoring mentoring) { + if (mentoring.getVerifyStatus() != VerifyStatus.PENDING) { + throw new CustomException(MENTORING_ALREADY_CONFIRMED); + } + } + + // 멘토는 본인의 멘토링에 대해 confirm 및 check해야 한다. + private void validateMentoringOwnership(Mentor mentor, Mentoring mentoring) { + if (mentoring.getMentorId() != mentor.getId()) { + throw new CustomException(UNAUTHORIZED_MENTORING); + } + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java b/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java new file mode 100644 index 000000000..1ef037706 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java @@ -0,0 +1,112 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; + +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatRoomRepository; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.MentoringForMenteeResponse; +import com.example.solidconnection.mentor.dto.MentoringForMentorResponse; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MentoringQueryService { + + private final MentoringRepository mentoringRepository; + private final MentorRepository mentorRepository; + private final SiteUserRepository siteUserRepository; + private final ChatRoomRepository chatRoomRepository; + + @Transactional(readOnly = true) + public SliceResponse getMentoringsForMentee( + long siteUserId, VerifyStatus verifyStatus, Pageable pageable + ) { + if (verifyStatus == VerifyStatus.REJECTED) { + throw new CustomException(ErrorCode.UNAUTHORIZED_MENTORING, "거절된 멘토링은 조회할 수 없습니다."); + } + Slice mentoringSlice = mentoringRepository.findAllByMenteeIdAndVerifyStatus(siteUserId, verifyStatus, pageable); + + Map mentoringToPartnerUser = mapMentoringToPartnerUserWithBatchQuery( + mentoringSlice.toList(), + Mentoring::getMentorId + ); + Map mentoringIdToChatRoomId = mapMentoringIdToChatRoomIdWithBatchQuery(mentoringSlice.getContent()); + + List content = new ArrayList<>(); + for (Mentoring mentoring : mentoringSlice) { + content.add(MentoringForMenteeResponse.of( + mentoring, + mentoringToPartnerUser.get(mentoring), + mentoringIdToChatRoomId.get(mentoring.getId()) + )); + } + return SliceResponse.of(content, mentoringSlice); + } + + // N+1 을 해결하면서 멘토링의 채팅방 정보 조회 + private Map mapMentoringIdToChatRoomIdWithBatchQuery(List mentorings) { + List mentoringIds = mentorings.stream() + .map(Mentoring::getId) + .distinct() + .toList(); + List chatRooms = chatRoomRepository.findAllByMentoringIdIn(mentoringIds); + return chatRooms.stream() + .collect(Collectors.toMap(ChatRoom::getMentoringId, ChatRoom::getId)); + } + + @Transactional(readOnly = true) + public SliceResponse getMentoringsForMentor(long siteUserId, Pageable pageable) { + Mentor mentor = mentorRepository.findBySiteUserId(siteUserId) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + Slice mentoringSlice = mentoringRepository.findAllByMentorId(mentor.getId(), pageable); + + Map mentoringToPartnerUser = mapMentoringToPartnerUserWithBatchQuery( + mentoringSlice.toList(), + Mentoring::getMenteeId + ); + + List content = new ArrayList<>(); + for (Mentoring mentoring : mentoringSlice) { + content.add(MentoringForMentorResponse.of(mentoring, mentoringToPartnerUser.get(mentoring))); + } + + return SliceResponse.of(content, mentoringSlice); + } + + // N+1 을 해결하면서 멘토링 상대방의 정보를 조회 + private Map mapMentoringToPartnerUserWithBatchQuery( + List mentorings, Function getPartnerId + ) { + List partnerUserId = mentorings.stream() + .map(getPartnerId) + .distinct() + .toList(); + List partnerUsers = siteUserRepository.findAllById(partnerUserId); + Map partnerIdToPartnerUsermap = partnerUsers.stream() + .collect(Collectors.toMap(SiteUser::getId, Function.identity())); + + return mentorings.stream().collect(Collectors.toMap( + Function.identity(), + mentoring -> partnerIdToPartnerUsermap.get(getPartnerId.apply(mentoring)) + )); + } +} diff --git a/src/main/java/com/example/solidconnection/news/config/NewsProperties.java b/src/main/java/com/example/solidconnection/news/config/NewsProperties.java new file mode 100644 index 000000000..3289ab7de --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/config/NewsProperties.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.news.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "news") +public record NewsProperties( + String defaultThumbnailUrl +) { + +} diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java new file mode 100644 index 000000000..263124a18 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -0,0 +1,100 @@ +package com.example.solidconnection.news.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsListResponse; +import com.example.solidconnection.news.dto.NewsUpdateRequest; +import com.example.solidconnection.news.service.NewsCommandService; +import com.example.solidconnection.news.service.NewsLikeService; +import com.example.solidconnection.news.service.NewsQueryService; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/news") +public class NewsController { + + private final NewsQueryService newsQueryService; + private final NewsCommandService newsCommandService; + private final NewsLikeService newsLikeService; + + // todo: 추후 Slice 적용 + @GetMapping + public ResponseEntity findNewsBySiteUserId( + @AuthorizedUser(required = false) Long siteUserId, + @RequestParam(value = "author-id") Long authorId + ) { + NewsListResponse newsListResponse = newsQueryService.findNewsByAuthorId(siteUserId, authorId); + return ResponseEntity.ok(newsListResponse); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PostMapping + public ResponseEntity createNews( + @AuthorizedUser long siteUserId, + @Valid @RequestPart("newsCreateRequest") NewsCreateRequest newsCreateRequest, + @RequestParam(value = "file", required = false) MultipartFile imageFile + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.createNews(siteUserId, newsCreateRequest, imageFile); + return ResponseEntity.ok(newsCommandResponse); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PutMapping("/{news-id}") + public ResponseEntity updateNews( + @AuthorizedUser long siteUserId, + @PathVariable("news-id") Long newsId, + @Valid @RequestPart(value = "newsUpdateRequest") NewsUpdateRequest newsUpdateRequest, + @RequestParam(value = "file", required = false) MultipartFile imageFile + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.updateNews( + siteUserId, + newsId, + newsUpdateRequest, + imageFile); + return ResponseEntity.ok(newsCommandResponse); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @DeleteMapping("/{news-id}") + public ResponseEntity deleteNewsById( + @AuthorizedUser long siteUserId, + @PathVariable("news-id") Long newsId + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.deleteNewsById(siteUserId, newsId); + return ResponseEntity.ok(newsCommandResponse); + } + + @PostMapping("/{news-id}/like") + public ResponseEntity addNewsLike( + @AuthorizedUser long siteUserId, + @PathVariable("news-id") Long newsId + ) { + newsLikeService.addNewsLike(siteUserId, newsId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{news-id}/like") + public ResponseEntity cancelNewsLike( + @AuthorizedUser long siteUserId, + @PathVariable("news-id") Long newsId + ) { + newsLikeService.cancelNewsLike(siteUserId, newsId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/news/domain/LikedNews.java b/src/main/java/com/example/solidconnection/news/domain/LikedNews.java new file mode 100644 index 000000000..9b7affad7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/domain/LikedNews.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.news.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_liked_news_site_user_id_news_id", + columnNames = {"site_user_id", "news_id"} + ) +}) +public class LikedNews { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "news_id") + private long newsId; + + @Column(name = "site_user_id") + private long siteUserId; + + public LikedNews(long newsId, long siteUserId) { + this.newsId = newsId; + this.siteUserId = siteUserId; + } +} diff --git a/src/main/java/com/example/solidconnection/news/domain/News.java b/src/main/java/com/example/solidconnection/news/domain/News.java new file mode 100644 index 000000000..5443f65aa --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/domain/News.java @@ -0,0 +1,60 @@ +package com.example.solidconnection.news.domain; + +import com.example.solidconnection.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class News extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String description; + + @Column(length = 500) + private String thumbnailUrl; + + @Column(length = 500) + private String url; + + private long siteUserId; + + public News( + String title, + String description, + String thumbnailUrl, + String url, + long siteUserId) { + this.title = title; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.url = url; + this.siteUserId = siteUserId; + } + + public void updateNews(String title, String description, String url) { + this.title = title; + this.description = description; + this.url = url; + } + + public void updateThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java new file mode 100644 index 000000000..bbc3059e1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; + +public record NewsCommandResponse( + long id +) { + + public static NewsCommandResponse from(News news) { + return new NewsCommandResponse( + news.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java new file mode 100644 index 000000000..c1359ae9d --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.URL; + +public record NewsCreateRequest( + @NotBlank(message = "소식지 제목을 입력해주세요.") + @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") + String title, + + @NotBlank(message = "소식지 내용을 입력해주세요.") + @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") + String description, + + @NotBlank(message = "소식지 URL을 입력해주세요.") + @Size(max = 500, message = "소식지 URL은 500자 이하여야 합니다.") + @URL(message = "올바른 URL 형식이 아닙니다.") + String url +) { + + public News toEntity(String thumbnailUrl, long siteUserId) { + return new News( + title, + description, + thumbnailUrl, + url, + siteUserId + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java new file mode 100644 index 000000000..29d3a1544 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.news.dto; + +import java.util.List; + +public record NewsListResponse( + List newsResponseList +) { + + public static NewsListResponse from(List newsResponseList) { + return new NewsListResponse(newsResponseList); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java new file mode 100644 index 000000000..d344080ba --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.news.dto; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +import com.example.solidconnection.news.domain.News; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.ZonedDateTime; + +public record NewsResponse( + long id, + String title, + String description, + String thumbnailUrl, + String url, + + @JsonInclude(NON_NULL) + Boolean isLiked, + + ZonedDateTime updatedAt +) { + + public static NewsResponse of(News news, Boolean isLiked) { + return new NewsResponse( + news.getId(), + news.getTitle(), + news.getDescription(), + news.getThumbnailUrl(), + news.getUrl(), + isLiked, + news.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java new file mode 100644 index 000000000..df396d92b --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.news.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.URL; + +public record NewsUpdateRequest( + @NotBlank(message = "소식지 제목을 입력해주세요.") + @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") + String title, + + @NotBlank(message = "소식지 내용을 입력해주세요.") + @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") + String description, + + @NotBlank(message = "소식지 URL을 입력해주세요.") + @Size(max = 500, message = "소식지 URL은 500자 이하여야 합니다.") + @URL(message = "올바른 URL 형식이 아닙니다.") + String url, + + Boolean resetToDefaultImage +) { + +} diff --git a/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java new file mode 100644 index 000000000..26e3d8e3c --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.news.repository; + +import com.example.solidconnection.news.domain.LikedNews; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LikedNewsRepository extends JpaRepository { + + boolean existsByNewsIdAndSiteUserId(long newsId, long siteUserId); + + Optional findByNewsIdAndSiteUserId(long newsId, long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java new file mode 100644 index 000000000..0d3ccf3e9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.news.repository; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.repository.custom.NewsCustomRepository; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewsRepository extends JpaRepository, NewsCustomRepository { + + List findAllBySiteUserIdOrderByUpdatedAtDesc(long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/news/repository/custom/NewsCustomRepository.java b/src/main/java/com/example/solidconnection/news/repository/custom/NewsCustomRepository.java new file mode 100644 index 000000000..ebba659e0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/custom/NewsCustomRepository.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.news.repository.custom; + +import com.example.solidconnection.news.dto.NewsResponse; +import java.util.List; + +public interface NewsCustomRepository { + + List findNewsByAuthorIdWithLikeStatus(long authorId, Long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/news/repository/custom/NewsCustomRepositoryImpl.java b/src/main/java/com/example/solidconnection/news/repository/custom/NewsCustomRepositoryImpl.java new file mode 100644 index 000000000..949d188bc --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/custom/NewsCustomRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.news.repository.custom; + +import com.example.solidconnection.news.dto.NewsResponse; +import jakarta.persistence.EntityManager; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class NewsCustomRepositoryImpl implements NewsCustomRepository { + + private final EntityManager entityManager; + + @Override + public List findNewsByAuthorIdWithLikeStatus(long authorId, Long siteUserId) { + String jpql = """ + SELECT new com.example.solidconnection.news.dto.NewsResponse( + n.id, + n.title, + n.description, + n.thumbnailUrl, + n.url, + CASE WHEN ln.id IS NOT NULL THEN true ELSE false END, + n.updatedAt + ) + FROM News n + LEFT JOIN LikedNews ln ON n.id = ln.newsId AND ln.siteUserId = :siteUserId + WHERE n.siteUserId = :authorId + ORDER BY n.updatedAt DESC + """; + + return entityManager.createQuery(jpql, NewsResponse.class) + .setParameter("authorId", authorId) + .setParameter("siteUserId", siteUserId) + .getResultList(); + } +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java new file mode 100644 index 000000000..ca1b262fe --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -0,0 +1,107 @@ +package com.example.solidconnection.news.service; + +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_NEWS_ACCESS; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.config.NewsProperties; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsUpdateRequest; +import com.example.solidconnection.news.repository.NewsRepository; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class NewsCommandService { + + private final S3Service s3Service; + private final NewsProperties newsProperties; + private final NewsRepository newsRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional + public NewsCommandResponse createNews(long siteUserId, NewsCreateRequest newsCreateRequest, MultipartFile imageFile) { + String thumbnailUrl = getImageUrl(imageFile); + News news = newsCreateRequest.toEntity(thumbnailUrl, siteUserId); + News savedNews = newsRepository.save(news); + return NewsCommandResponse.from(savedNews); + } + + private String getImageUrl(MultipartFile imageFile) { + if (imageFile != null && !imageFile.isEmpty()) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + return uploadedFile.fileUrl(); + } + return newsProperties.defaultThumbnailUrl(); + } + + @Transactional + public NewsCommandResponse updateNews( + long siteUserId, + Long newsId, + NewsUpdateRequest newsUpdateRequest, + MultipartFile imageFile) { + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + validateOwnership(news, siteUserId); + news.updateNews(newsUpdateRequest.title(), newsUpdateRequest.description(), newsUpdateRequest.url()); + updateThumbnail(news, imageFile, newsUpdateRequest.resetToDefaultImage()); + News savedNews = newsRepository.save(news); + return NewsCommandResponse.from(savedNews); + } + + private void validateOwnership(News news, long siteUserId) { + if (news.getSiteUserId() != siteUserId) { + throw new CustomException(INVALID_NEWS_ACCESS); + } + } + + private void updateThumbnail(News news, MultipartFile imageFile, Boolean resetToDefaultImage) { + if (Boolean.TRUE.equals(resetToDefaultImage)) { + deleteCustomImage(news.getThumbnailUrl()); + news.updateThumbnailUrl(newsProperties.defaultThumbnailUrl()); + } else if (imageFile != null && !imageFile.isEmpty()) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + deleteCustomImage(news.getThumbnailUrl()); + news.updateThumbnailUrl(uploadedFile.fileUrl()); + } + } + + @Transactional + public NewsCommandResponse deleteNewsById(long siteUserId, Long newsId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + validatePermission(siteUser, news); + deleteCustomImage(news.getThumbnailUrl()); + newsRepository.delete(news); + return NewsCommandResponse.from(news); + } + + private void validatePermission(SiteUser currentUser, News news) { + boolean isOwner = news.getSiteUserId() == currentUser.getId(); + boolean isAdmin = currentUser.getRole().equals(Role.ADMIN); + if (!isOwner && !isAdmin) { + throw new CustomException(INVALID_NEWS_ACCESS); + } + } + + private void deleteCustomImage(String imageUrl) { + if (!newsProperties.defaultThumbnailUrl().equals(imageUrl)) { + s3Service.deletePostImage(imageUrl); + } + } +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsLikeService.java b/src/main/java/com/example/solidconnection/news/service/NewsLikeService.java new file mode 100644 index 000000000..4b9435ab6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsLikeService.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.news.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_LIKED_NEWS; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_LIKED_NEWS; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.LikedNews; +import com.example.solidconnection.news.repository.LikedNewsRepository; +import com.example.solidconnection.news.repository.NewsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class NewsLikeService { + + private final NewsRepository newsRepository; + private final LikedNewsRepository likedNewsRepository; + + @Transactional + public void addNewsLike(long siteUserId, long newsId) { + if (!newsRepository.existsById(newsId)) { + throw new CustomException(NEWS_NOT_FOUND); + } + if (likedNewsRepository.existsByNewsIdAndSiteUserId(newsId, siteUserId)) { + throw new CustomException(ALREADY_LIKED_NEWS); + } + LikedNews likedNews = new LikedNews(newsId, siteUserId); + likedNewsRepository.save(likedNews); + } + + @Transactional + public void cancelNewsLike(long siteUserId, long newsId) { + if (!newsRepository.existsById(newsId)) { + throw new CustomException(NEWS_NOT_FOUND); + } + LikedNews likedNews = likedNewsRepository.findByNewsIdAndSiteUserId(newsId, siteUserId) + .orElseThrow(() -> new CustomException(NOT_LIKED_NEWS)); + likedNewsRepository.delete(likedNews); + } +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java new file mode 100644 index 000000000..e0050643b --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.news.dto.NewsListResponse; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.repository.NewsRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NewsQueryService { + + private final NewsRepository newsRepository; + + @Transactional(readOnly = true) + public NewsListResponse findNewsByAuthorId(Long siteUserId, long authorId) { + // 로그인하지 않은 경우 + if (siteUserId == null) { + List newsResponseList = newsRepository.findAllBySiteUserIdOrderByUpdatedAtDesc(authorId) + .stream() + .map(news -> NewsResponse.of(news, null)) + .toList(); + return NewsListResponse.from(newsResponseList); + } + + // 로그인한 경우 + List newsResponseList = newsRepository.findNewsByAuthorIdWithLikeStatus(authorId, siteUserId); + + return NewsListResponse.from(newsResponseList); + } +} diff --git a/src/main/java/com/example/solidconnection/report/controller/ReportController.java b/src/main/java/com/example/solidconnection/report/controller/ReportController.java new file mode 100644 index 000000000..2bf436fc0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/controller/ReportController.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.report.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.report.dto.ReportRequest; +import com.example.solidconnection.report.service.ReportService; +import jakarta.validation.Valid; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/reports") +public class ReportController { + + private final ReportService reportService; + + @PostMapping + public ResponseEntity createReport( + @AuthorizedUser long siteUserId, + @Valid @RequestBody ReportRequest reportRequest + ) { + reportService.createReport(siteUserId, reportRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/report/domain/Report.java b/src/main/java/com/example/solidconnection/report/domain/Report.java new file mode 100644 index 000000000..e723db281 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/domain/Report.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.report.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 jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_report_reporter_id_target_type_target_id", + columnNames = {"reporter_id", "target_type", "target_id"} + ) +}) +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "reporter_id") + private long reporterId; + + @Column(name = "report_type") + @Enumerated(value = EnumType.STRING) + private ReportType reportType; + + @Column(name = "target_type") + @Enumerated(value = EnumType.STRING) + private TargetType targetType; + + @Column(name = "target_id") + private long targetId; + + public Report(long reporterId, ReportType reportType, TargetType targetType, long targetId) { + this.reportType = reportType; + this.reporterId = reporterId; + this.targetType = targetType; + this.targetId = targetId; + } +} diff --git a/src/main/java/com/example/solidconnection/report/domain/ReportType.java b/src/main/java/com/example/solidconnection/report/domain/ReportType.java new file mode 100644 index 000000000..18a5e5d9b --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/domain/ReportType.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.report.domain; + +public enum ReportType { + + ADVERTISEMENT, // 광고 + SPAM, // 낚시/도배 + PERSONAL_INFO_EXPOSURE, // 개인정보 노출 + PORNOGRAPHY, // 선정성 + COPYRIGHT_INFRINGEMENT, // 저작권 침해 + ILLEGAL_ACTIVITY, // 불법 행위 + IMPERSONATION, // 사칭/도용 + INSULT, // 욕설/비하 + ; +} diff --git a/src/main/java/com/example/solidconnection/report/domain/TargetType.java b/src/main/java/com/example/solidconnection/report/domain/TargetType.java new file mode 100644 index 000000000..c48f50ac0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/domain/TargetType.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.report.domain; + +public enum TargetType { + + POST, + ; +} diff --git a/src/main/java/com/example/solidconnection/report/dto/ReportRequest.java b/src/main/java/com/example/solidconnection/report/dto/ReportRequest.java new file mode 100644 index 000000000..52f608015 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/dto/ReportRequest.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.report.dto; + +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; +import jakarta.validation.constraints.NotNull; + +public record ReportRequest( + @NotNull(message = "신고 유형을 선택해주세요.") + ReportType reportType, + + @NotNull(message = "신고 대상을 포함해주세요.") + TargetType targetType, + + long targetId +) { + +} diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java new file mode 100644 index 000000000..c32d3cd5f --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.report.repository; + +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.TargetType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); +} diff --git a/src/main/java/com/example/solidconnection/report/service/ReportService.java b/src/main/java/com/example/solidconnection/report/service/ReportService.java new file mode 100644 index 000000000..3546861ea --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/service/ReportService.java @@ -0,0 +1,50 @@ +package com.example.solidconnection.report.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.dto.ReportRequest; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final ReportRepository reportRepository; + private final SiteUserRepository siteUserRepository; + private final PostRepository postRepository; + + @Transactional + public void createReport(long reporterId, ReportRequest request) { + validateReporterExists(reporterId); + validateTargetExists(request.targetType(), request.targetId()); + validateFirstReportByUser(reporterId, request.targetType(), request.targetId()); + + Report report = new Report(reporterId, request.reportType(), request.targetType(), request.targetId()); + reportRepository.save(report); + } + + private void validateReporterExists(long reporterId) { + if (!siteUserRepository.existsById(reporterId)) { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + } + + private void validateTargetExists(TargetType targetType, long targetId) { + if (targetType == TargetType.POST && !postRepository.existsById(targetId)) { + throw new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND); + } + } + + private void validateFirstReportByUser(long reporterId, TargetType targetType, long targetId) { + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, targetType, targetId)) { + throw new CustomException(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER); + } + } +} diff --git a/src/main/java/com/example/solidconnection/repositories/CountryRepository.java b/src/main/java/com/example/solidconnection/repositories/CountryRepository.java deleted file mode 100644 index d9ba75555..000000000 --- a/src/main/java/com/example/solidconnection/repositories/CountryRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.solidconnection.repositories; - -import com.example.solidconnection.entity.Country; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface CountryRepository extends JpaRepository { - - @Query("SELECT c FROM Country c WHERE c.koreanName IN :names") - List findByKoreanNames(@Param(value = "names") List names); -} diff --git a/src/main/java/com/example/solidconnection/repositories/InterestedCountyRepository.java b/src/main/java/com/example/solidconnection/repositories/InterestedCountyRepository.java deleted file mode 100644 index 68e10b320..000000000 --- a/src/main/java/com/example/solidconnection/repositories/InterestedCountyRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.solidconnection.repositories; - -import com.example.solidconnection.entity.InterestedCountry; -import com.example.solidconnection.siteuser.domain.SiteUser; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface InterestedCountyRepository extends JpaRepository { - List findAllBySiteUser(SiteUser siteUser); -} diff --git a/src/main/java/com/example/solidconnection/repositories/InterestedRegionRepository.java b/src/main/java/com/example/solidconnection/repositories/InterestedRegionRepository.java deleted file mode 100644 index df5acd696..000000000 --- a/src/main/java/com/example/solidconnection/repositories/InterestedRegionRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.solidconnection.repositories; - -import com.example.solidconnection.entity.InterestedRegion; -import com.example.solidconnection.siteuser.domain.SiteUser; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface InterestedRegionRepository extends JpaRepository { - List findAllBySiteUser(SiteUser siteUser); -} diff --git a/src/main/java/com/example/solidconnection/repositories/RegionRepository.java b/src/main/java/com/example/solidconnection/repositories/RegionRepository.java deleted file mode 100644 index 0dc99fb08..000000000 --- a/src/main/java/com/example/solidconnection/repositories/RegionRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.solidconnection.repositories; - -import com.example.solidconnection.entity.Region; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface RegionRepository extends JpaRepository { - - @Query("SELECT r FROM Region r WHERE r.koreanName IN :names") - List findByKoreanNames(@Param(value = "names") List names); -} diff --git a/src/main/java/com/example/solidconnection/s3/AmazonS3Config.java b/src/main/java/com/example/solidconnection/s3/config/AmazonS3Config.java similarity index 95% rename from src/main/java/com/example/solidconnection/s3/AmazonS3Config.java rename to src/main/java/com/example/solidconnection/s3/config/AmazonS3Config.java index c12f067dd..3b19cecfa 100644 --- a/src/main/java/com/example/solidconnection/s3/AmazonS3Config.java +++ b/src/main/java/com/example/solidconnection/s3/config/AmazonS3Config.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.s3; +package com.example.solidconnection.s3.config; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; diff --git a/src/main/java/com/example/solidconnection/s3/S3Controller.java b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java similarity index 84% rename from src/main/java/com/example/solidconnection/s3/S3Controller.java rename to src/main/java/com/example/solidconnection/s3/controller/S3Controller.java index 26f9160c0..1bd978627 100644 --- a/src/main/java/com/example/solidconnection/s3/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java @@ -1,8 +1,10 @@ -package com.example.solidconnection.s3; +package com.example.solidconnection.s3.controller; -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.dto.urlPrefixResponse; +import com.example.solidconnection.s3.service.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -42,11 +44,11 @@ public ResponseEntity uploadPreProfileImage( @PostMapping("/profile/post") public ResponseEntity uploadPostProfileImage( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @RequestParam("file") MultipartFile imageFile ) { UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE); - s3Service.deleteExProfile(siteUser); + s3Service.deleteExProfile(siteUserId); return ResponseEntity.ok(profileImageUrl); } diff --git a/src/main/java/com/example/solidconnection/type/ImgType.java b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java similarity index 71% rename from src/main/java/com/example/solidconnection/type/ImgType.java rename to src/main/java/com/example/solidconnection/s3/domain/ImgType.java index 45eb516bb..7efedb1a5 100644 --- a/src/main/java/com/example/solidconnection/type/ImgType.java +++ b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java @@ -1,10 +1,10 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.s3.domain; import lombok.Getter; @Getter public enum ImgType { - PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"); + PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"), NEWS("news"); private final String type; diff --git a/src/main/java/com/example/solidconnection/s3/UploadedFileUrlResponse.java b/src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java similarity index 60% rename from src/main/java/com/example/solidconnection/s3/UploadedFileUrlResponse.java rename to src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java index 6d9b690fa..0a4722807 100644 --- a/src/main/java/com/example/solidconnection/s3/UploadedFileUrlResponse.java +++ b/src/main/java/com/example/solidconnection/s3/dto/UploadedFileUrlResponse.java @@ -1,5 +1,6 @@ -package com.example.solidconnection.s3; +package com.example.solidconnection.s3.dto; public record UploadedFileUrlResponse( String fileUrl) { + } diff --git a/src/main/java/com/example/solidconnection/s3/urlPrefixResponse.java b/src/main/java/com/example/solidconnection/s3/dto/urlPrefixResponse.java similarity index 78% rename from src/main/java/com/example/solidconnection/s3/urlPrefixResponse.java rename to src/main/java/com/example/solidconnection/s3/dto/urlPrefixResponse.java index 59eac23ca..ab4d2f68b 100644 --- a/src/main/java/com/example/solidconnection/s3/urlPrefixResponse.java +++ b/src/main/java/com/example/solidconnection/s3/dto/urlPrefixResponse.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.s3; +package com.example.solidconnection.s3.dto; public record urlPrefixResponse( String s3Default, @@ -6,4 +6,5 @@ public record urlPrefixResponse( String cloudFrontDefault, String cloudFrontUploaded ) { + } diff --git a/src/main/java/com/example/solidconnection/s3/FileUploadService.java b/src/main/java/com/example/solidconnection/s3/service/FileUploadService.java similarity index 85% rename from src/main/java/com/example/solidconnection/s3/FileUploadService.java rename to src/main/java/com/example/solidconnection/s3/service/FileUploadService.java index 71d9f9c7a..51ef4caa9 100644 --- a/src/main/java/com/example/solidconnection/s3/FileUploadService.java +++ b/src/main/java/com/example/solidconnection/s3/service/FileUploadService.java @@ -1,4 +1,7 @@ -package com.example.solidconnection.s3; +package com.example.solidconnection.s3.service; + +import static com.example.solidconnection.common.exception.ErrorCode.S3_CLIENT_EXCEPTION; +import static com.example.solidconnection.common.exception.ErrorCode.S3_SERVICE_EXCEPTION; import com.amazonaws.AmazonServiceException; import com.amazonaws.SdkClientException; @@ -6,18 +9,14 @@ import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.exception.CustomException; +import java.io.IOException; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; - -import static com.example.solidconnection.custom.exception.ErrorCode.S3_CLIENT_EXCEPTION; -import static com.example.solidconnection.custom.exception.ErrorCode.S3_SERVICE_EXCEPTION; - @Component @EnableAsync @Slf4j @@ -39,7 +38,7 @@ public void uploadFile(String bucket, String fileName, MultipartFile multipartFi try { amazonS3.putObject(new PutObjectRequest(bucket, fileName, multipartFile.getInputStream(), metadata) - .withCannedAcl(CannedAccessControlList.PublicRead)); + .withCannedAcl(CannedAccessControlList.PublicRead)); log.info("이미지 업로드 정상적 완료 thread: {}", Thread.currentThread().getName()); } catch (AmazonServiceException e) { log.error("이미지 업로드 중 s3 서비스 예외 발생 : {}", e.getMessage()); diff --git a/src/main/java/com/example/solidconnection/s3/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java similarity index 84% rename from src/main/java/com/example/solidconnection/s3/S3Service.java rename to src/main/java/com/example/solidconnection/s3/service/S3Service.java index 2f3c633dd..11b66a499 100644 --- a/src/main/java/com/example/solidconnection/s3/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -1,13 +1,27 @@ -package com.example.solidconnection.s3; +package com.example.solidconnection.s3.service; + +import static com.example.solidconnection.common.exception.ErrorCode.FILE_NOT_EXIST; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_FILE_EXTENSIONS; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS; +import static com.example.solidconnection.common.exception.ErrorCode.S3_CLIENT_EXCEPTION; +import static com.example.solidconnection.common.exception.ErrorCode.S3_SERVICE_EXCEPTION; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.amazonaws.AmazonServiceException; import com.amazonaws.SdkClientException; import com.amazonaws.services.s3.AmazonS3Client; import com.amazonaws.services.s3.model.DeleteObjectRequest; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.ImgType; +import jakarta.transaction.Transactional; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,18 +30,6 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.UUID; - -import static com.example.solidconnection.custom.exception.ErrorCode.FILE_NOT_EXIST; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_FILE_EXTENSIONS; -import static com.example.solidconnection.custom.exception.ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS; -import static com.example.solidconnection.custom.exception.ErrorCode.S3_CLIENT_EXCEPTION; -import static com.example.solidconnection.custom.exception.ErrorCode.S3_SERVICE_EXCEPTION; - @Service @RequiredArgsConstructor public class S3Service { @@ -109,7 +111,10 @@ private String getFileExtension(String fileName) { * - 기존 파일의 key(S3파일명)를 찾는다. * - S3에서 파일을 삭제한다. * */ - public void deleteExProfile(SiteUser siteUser) { + @Transactional + public void deleteExProfile(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); String key = siteUser.getProfileImageUrl(); deleteFile(key); } diff --git a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java index 8da1fe1ca..746b50927 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java @@ -1,7 +1,10 @@ package com.example.solidconnection.scheduler; -import com.example.solidconnection.service.UpdateViewCountService; +import static com.example.solidconnection.community.post.service.RedisConstants.VIEW_COUNT_KEY_PATTERN; + +import com.example.solidconnection.community.post.service.UpdateViewCountService; import com.example.solidconnection.util.RedisUtils; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -11,10 +14,6 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; -import java.util.List; - -import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_KEY_PATTERN; - @RequiredArgsConstructor @Component @EnableScheduling diff --git a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java index 0af509833..da39cf2e9 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java @@ -2,13 +2,12 @@ import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.LocalDate; -import java.util.List; - @RequiredArgsConstructor @Component public class UserRemovalScheduler { diff --git a/src/main/java/com/example/solidconnection/score/controller/ScoreController.java b/src/main/java/com/example/solidconnection/score/controller/ScoreController.java index e67639274..3689cf750 100644 --- a/src/main/java/com/example/solidconnection/score/controller/ScoreController.java +++ b/src/main/java/com/example/solidconnection/score/controller/ScoreController.java @@ -1,12 +1,11 @@ package com.example.solidconnection.score.controller; -import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.score.dto.GpaScoreRequest; import com.example.solidconnection.score.dto.GpaScoreStatusesResponse; import com.example.solidconnection.score.dto.LanguageTestScoreRequest; import com.example.solidconnection.score.dto.LanguageTestScoreStatusesResponse; import com.example.solidconnection.score.service.ScoreService; -import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -28,40 +27,40 @@ public class ScoreController { // 학점을 등록하는 api @PostMapping("/gpas") public ResponseEntity submitGpaScore( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @Valid @RequestPart("gpaScoreRequest") GpaScoreRequest gpaScoreRequest, @RequestParam("file") MultipartFile file ) { - Long id = scoreService.submitGpaScore(siteUser, gpaScoreRequest, file); + Long id = scoreService.submitGpaScore(siteUserId, gpaScoreRequest, file); return ResponseEntity.ok(id); } // 어학성적을 등록하는 api @PostMapping("/language-tests") public ResponseEntity submitLanguageTestScore( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @Valid @RequestPart("languageTestScoreRequest") LanguageTestScoreRequest languageTestScoreRequest, @RequestParam("file") MultipartFile file ) { - Long id = scoreService.submitLanguageTestScore(siteUser, languageTestScoreRequest, file); + Long id = scoreService.submitLanguageTestScore(siteUserId, languageTestScoreRequest, file); return ResponseEntity.ok(id); } // 학점 상태를 확인하는 api @GetMapping("/gpas") public ResponseEntity getGpaScoreStatus( - @AuthorizedUser SiteUser siteUser + @AuthorizedUser long siteUserId ) { - GpaScoreStatusesResponse gpaScoreStatus = scoreService.getGpaScoreStatus(siteUser); + GpaScoreStatusesResponse gpaScoreStatus = scoreService.getGpaScoreStatus(siteUserId); return ResponseEntity.ok(gpaScoreStatus); } // 어학 성적 상태를 확인하는 api @GetMapping("/language-tests") public ResponseEntity getLanguageTestScoreStatus( - @AuthorizedUser SiteUser siteUser + @AuthorizedUser long siteUserId ) { - LanguageTestScoreStatusesResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(siteUser); + LanguageTestScoreStatusesResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(siteUserId); return ResponseEntity.ok(languageTestScoreStatus); } } diff --git a/src/main/java/com/example/solidconnection/score/domain/GpaScore.java b/src/main/java/com/example/solidconnection/score/domain/GpaScore.java index ddc583aa7..7e8536d83 100644 --- a/src/main/java/com/example/solidconnection/score/domain/GpaScore.java +++ b/src/main/java/com/example/solidconnection/score/domain/GpaScore.java @@ -1,9 +1,9 @@ package com.example.solidconnection.score.domain; import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.common.BaseEntity; +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.VerifyStatus; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -12,7 +12,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,30 +31,21 @@ public class GpaScore extends BaseEntity { private Gpa gpa; @Setter - @Column(columnDefinition = "varchar(50) not null default 'PENDING'") + @Column(nullable = false) @Enumerated(EnumType.STRING) - private VerifyStatus verifyStatus; + private VerifyStatus verifyStatus = VerifyStatus.PENDING; private String rejectedReason; - @ManyToOne - private SiteUser siteUser; + private long siteUserId; public GpaScore(Gpa gpa, SiteUser siteUser) { this.gpa = gpa; - this.siteUser = siteUser; + this.siteUserId = siteUser.getId(); this.verifyStatus = VerifyStatus.PENDING; this.rejectedReason = null; } - public void setSiteUser(SiteUser siteUser) { - if (this.siteUser != null) { - this.siteUser.getGpaScoreList().remove(this); - } - this.siteUser = siteUser; - siteUser.getGpaScoreList().add(this); - } - public void updateGpaScore(Gpa gpa, VerifyStatus verifyStatus, String rejectedReason) { this.gpa = gpa; this.verifyStatus = verifyStatus; diff --git a/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java b/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java index ec791373e..415519b7d 100644 --- a/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java +++ b/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java @@ -1,9 +1,9 @@ package com.example.solidconnection.score.domain; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.common.BaseEntity; +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.VerifyStatus; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -12,7 +12,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,27 +31,18 @@ public class LanguageTestScore extends BaseEntity { private LanguageTest languageTest; @Setter - @Column(columnDefinition = "varchar(50) not null default 'PENDING'") + @Column(nullable = false) @Enumerated(EnumType.STRING) - private VerifyStatus verifyStatus; + private VerifyStatus verifyStatus = VerifyStatus.PENDING; private String rejectedReason; - @ManyToOne - private SiteUser siteUser; + private long siteUserId; public LanguageTestScore(LanguageTest languageTest, SiteUser siteUser) { this.languageTest = languageTest; this.verifyStatus = VerifyStatus.PENDING; - this.siteUser = siteUser; - } - - public void setSiteUser(SiteUser siteUser) { - if (this.siteUser != null) { - this.siteUser.getLanguageTestScoreList().remove(this); - } - this.siteUser = siteUser; - siteUser.getLanguageTestScoreList().add(this); + this.siteUserId = siteUser.getId(); } public void updateLanguageTestScore(LanguageTest languageTest, VerifyStatus verifyStatus, String rejectedReason) { diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaResponse.java b/src/main/java/com/example/solidconnection/score/dto/GpaResponse.java index fc05667aa..74a471049 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaResponse.java @@ -7,6 +7,7 @@ public record GpaResponse( double gpaCriteria, String gpaReportUrl ) { + public static GpaResponse from(Gpa gpa) { return new GpaResponse( gpa.getGpa(), diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java index a95cced6c..cdbf87bd8 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java @@ -10,4 +10,5 @@ public record GpaScoreRequest( @NotNull(message = "학점 기준을 입력해주세요.") Double gpaCriteria ) { + } diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java index df161d358..4d554269d 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.score.dto; +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.type.VerifyStatus; public record GpaScoreStatusResponse( long id, @@ -9,6 +9,7 @@ public record GpaScoreStatusResponse( VerifyStatus verifyStatus, String rejectedReason ) { + public static GpaScoreStatusResponse from(GpaScore gpaScore) { return new GpaScoreStatusResponse( gpaScore.getId(), diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java index 24a12af18..4d31d4252 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatusesResponse.java @@ -5,6 +5,7 @@ public record GpaScoreStatusesResponse( List gpaScoreStatusResponseList ) { + public static GpaScoreStatusesResponse from(List gpaScoreStatusResponseList) { return new GpaScoreStatusesResponse(gpaScoreStatusResponseList); } diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestResponse.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestResponse.java index 060574f46..d17f1632f 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestResponse.java @@ -1,13 +1,14 @@ package com.example.solidconnection.score.dto; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageTestType; public record LanguageTestResponse( LanguageTestType languageTestType, String languageTestScore, String languageTestReportUrl ) { + public static LanguageTestResponse from(LanguageTest languageTest) { return new LanguageTestResponse( languageTest.getLanguageTestType(), diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java index e49af4369..13bf31384 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java @@ -1,6 +1,6 @@ package com.example.solidconnection.score.dto; -import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.domain.LanguageTestType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -12,4 +12,5 @@ public record LanguageTestScoreRequest( @NotBlank(message = "어학 점수를 입력해주세요.") String languageTestScore ) { + } diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java index 3ee96906e..afd604906 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.score.dto; +import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.type.VerifyStatus; public record LanguageTestScoreStatusResponse( long id, @@ -9,6 +9,7 @@ public record LanguageTestScoreStatusResponse( VerifyStatus verifyStatus, String rejectedReason ) { + public static LanguageTestScoreStatusResponse from(LanguageTestScore languageTestScore) { return new LanguageTestScoreStatusResponse( languageTestScore.getId(), diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusesResponse.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusesResponse.java index 027794853..811928011 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusesResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusesResponse.java @@ -5,6 +5,7 @@ public record LanguageTestScoreStatusesResponse( List languageTestScoreStatusResponseList ) { + public static LanguageTestScoreStatusesResponse from(List languageTestScoreStatusResponseList) { return new LanguageTestScoreStatusesResponse(languageTestScoreStatusResponseList); } diff --git a/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java b/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java index 5610c8de3..207e36234 100644 --- a/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java +++ b/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java @@ -2,16 +2,15 @@ import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.repository.custom.GpaScoreFilterRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - +import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; -@Repository public interface GpaScoreRepository extends JpaRepository, GpaScoreFilterRepository { - Optional findGpaScoreBySiteUser(SiteUser siteUser); + Optional findGpaScoreBySiteUserId(long siteUserId); + + Optional findGpaScoreBySiteUserIdAndId(long siteUserId, Long id); - Optional findGpaScoreBySiteUserAndId(SiteUser siteUser, Long id); + List findBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java b/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java index 5bef377cf..40fe50106 100644 --- a/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java +++ b/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java @@ -2,17 +2,16 @@ import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.custom.LanguageTestScoreFilterRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.LanguageTestType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - +import com.example.solidconnection.university.domain.LanguageTestType; +import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; -@Repository public interface LanguageTestScoreRepository extends JpaRepository, LanguageTestScoreFilterRepository { - Optional findLanguageTestScoreBySiteUserAndLanguageTest_LanguageTestType(SiteUser siteUser, LanguageTestType languageTestType); + Optional findLanguageTestScoreBySiteUserIdAndLanguageTest_LanguageTestType(long siteUserId, LanguageTestType languageTestType); + + Optional findLanguageTestScoreBySiteUserIdAndId(long siteUserId, Long id); - Optional findLanguageTestScoreBySiteUserAndId(SiteUser siteUser, Long id); + List findBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/score/repository/custom/GpaScoreFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/score/repository/custom/GpaScoreFilterRepositoryImpl.java index a02e62b49..839b4340e 100644 --- a/src/main/java/com/example/solidconnection/score/repository/custom/GpaScoreFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/score/repository/custom/GpaScoreFilterRepositoryImpl.java @@ -1,31 +1,30 @@ package com.example.solidconnection.score.repository.custom; +import static com.example.solidconnection.score.domain.QGpaScore.gpaScore; +import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; +import static org.springframework.util.StringUtils.hasText; + import com.example.solidconnection.admin.dto.GpaResponse; import com.example.solidconnection.admin.dto.GpaScoreSearchResponse; import com.example.solidconnection.admin.dto.GpaScoreStatusResponse; import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.admin.dto.SiteUserResponse; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.common.VerifyStatus; import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; - -import static com.example.solidconnection.score.domain.QGpaScore.gpaScore; -import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; -import static org.springframework.util.StringUtils.hasText; - @Repository public class GpaScoreFilterRepositoryImpl implements GpaScoreFilterRepository { @@ -70,7 +69,7 @@ public Page searchGpaScores(ScoreSearchCondition conditi List content = queryFactory .select(GPA_SCORE_SEARCH_RESPONSE_PROJECTION) .from(gpaScore) - .join(gpaScore.siteUser, siteUser) + .join(siteUser).on(gpaScore.siteUserId.eq(siteUser.id)) .where( verifyStatusEq(condition.verifyStatus()), nicknameContains(condition.nickname()), @@ -84,7 +83,7 @@ public Page searchGpaScores(ScoreSearchCondition conditi Long totalCount = queryFactory .select(gpaScore.count()) .from(gpaScore) - .join(gpaScore.siteUser, siteUser) + .join(siteUser).on(gpaScore.siteUserId.eq(siteUser.id)) .where( verifyStatusEq(condition.verifyStatus()), nicknameContains(condition.nickname()), diff --git a/src/main/java/com/example/solidconnection/score/repository/custom/LanguageTestScoreFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/score/repository/custom/LanguageTestScoreFilterRepositoryImpl.java index 5d88c1451..d8f42f6a5 100644 --- a/src/main/java/com/example/solidconnection/score/repository/custom/LanguageTestScoreFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/score/repository/custom/LanguageTestScoreFilterRepositoryImpl.java @@ -1,31 +1,30 @@ package com.example.solidconnection.score.repository.custom; +import static com.example.solidconnection.score.domain.QLanguageTestScore.languageTestScore; +import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; +import static io.jsonwebtoken.lang.Strings.hasText; + import com.example.solidconnection.admin.dto.LanguageTestResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreSearchResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreStatusResponse; import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.admin.dto.SiteUserResponse; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.common.VerifyStatus; import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; - -import static com.example.solidconnection.score.domain.QLanguageTestScore.languageTestScore; -import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; -import static io.jsonwebtoken.lang.Strings.hasText; - @Repository public class LanguageTestScoreFilterRepositoryImpl implements LanguageTestScoreFilterRepository { @@ -70,7 +69,7 @@ public Page searchLanguageTestScores(ScoreSearc List content = queryFactory .select(LANGUAGE_TEST_SCORE_SEARCH_RESPONSE_PROJECTION) .from(languageTestScore) - .join(languageTestScore.siteUser, siteUser) + .join(siteUser).on(languageTestScore.siteUserId.eq(siteUser.id)) .where( verifyStatusEq(condition.verifyStatus()), nicknameContains(condition.nickname()), @@ -84,7 +83,7 @@ public Page searchLanguageTestScores(ScoreSearc Long totalCount = queryFactory .select(languageTestScore.count()) .from(languageTestScore) - .join(languageTestScore.siteUser, siteUser) + .join(siteUser).on(languageTestScore.siteUserId.eq(siteUser.id)) .where( verifyStatusEq(condition.verifyStatus()), nicknameContains(condition.nickname()), diff --git a/src/main/java/com/example/solidconnection/score/service/ScoreService.java b/src/main/java/com/example/solidconnection/score/service/ScoreService.java index eb00a14e9..f16951d49 100644 --- a/src/main/java/com/example/solidconnection/score/service/ScoreService.java +++ b/src/main/java/com/example/solidconnection/score/service/ScoreService.java @@ -1,10 +1,13 @@ package com.example.solidconnection.score.service; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.dto.GpaScoreRequest; @@ -17,19 +20,13 @@ import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.ImgType; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - @Service @RequiredArgsConstructor public class ScoreService { @@ -40,63 +37,52 @@ public class ScoreService { private final SiteUserRepository siteUserRepository; @Transactional - public Long submitGpaScore(SiteUser siteUser, GpaScoreRequest gpaScoreRequest, MultipartFile file) { + public Long submitGpaScore(long siteUserId, GpaScoreRequest gpaScoreRequest, MultipartFile file) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, ImgType.GPA); Gpa gpa = new Gpa(gpaScoreRequest.gpa(), gpaScoreRequest.gpaCriteria(), uploadedFile.fileUrl()); - - /* - * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, - * siteUser 에 gpaScoreList 를 FetchType.EAGER 로 설정할 것인지, - * gpa 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. - */ - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - GpaScore newGpaScore = new GpaScore(gpa, siteUser1); - newGpaScore.setSiteUser(siteUser1); - GpaScore savedNewGpaScore = gpaScoreRepository.save(newGpaScore); // 저장 후 반환된 객체 - return savedNewGpaScore.getId(); // 저장된 GPA Score의 ID 반환 + GpaScore newGpaScore = new GpaScore(gpa, siteUser); + GpaScore savedNewGpaScore = gpaScoreRepository.save(newGpaScore); + return savedNewGpaScore.getId(); } @Transactional - public Long submitLanguageTestScore(SiteUser siteUser, LanguageTestScoreRequest languageTestScoreRequest, MultipartFile file) { + public Long submitLanguageTestScore(long siteUserId, LanguageTestScoreRequest languageTestScoreRequest, MultipartFile file) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, ImgType.LANGUAGE_TEST); LanguageTest languageTest = new LanguageTest(languageTestScoreRequest.languageTestType(), - languageTestScoreRequest.languageTestScore(), uploadedFile.fileUrl()); - - /* - * todo: siteUser를 영속 상태로 만들 수 있도록 컨트롤러에서 siteUserId 를 넘겨줄 것인지, - * siteUser 에 languageTestScoreList 를 FetchType.EAGER 로 설정할 것인지, - * languageTest 와 siteUser 사이의 양방향을 끊을 것인지 생각해봐야한다. - */ - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - LanguageTestScore newScore = new LanguageTestScore(languageTest, siteUser1); - newScore.setSiteUser(siteUser1); - LanguageTestScore savedNewScore = languageTestScoreRepository.save(newScore); // 새로 저장한 객체 - return savedNewScore.getId(); // 저장된 객체의 ID 반환 + languageTestScoreRequest.languageTestScore(), uploadedFile.fileUrl()); + LanguageTestScore newScore = new LanguageTestScore(languageTest, siteUser); + LanguageTestScore savedNewScore = languageTestScoreRepository.save(newScore); + return savedNewScore.getId(); } @Transactional(readOnly = true) - public GpaScoreStatusesResponse getGpaScoreStatus(SiteUser siteUser) { - // todo: ditto - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + public GpaScoreStatusesResponse getGpaScoreStatus(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); List gpaScoreStatusResponseList = - Optional.ofNullable(siteUser1.getGpaScoreList()) - .map(scores -> scores.stream() - .map(GpaScoreStatusResponse::from) - .collect(Collectors.toList())) - .orElse(Collections.emptyList()); + gpaScoreRepository.findBySiteUserId(siteUser.getId()) + .stream() + .map(GpaScoreStatusResponse::from) + .toList(); + return GpaScoreStatusesResponse.from(gpaScoreStatusResponseList); } @Transactional(readOnly = true) - public LanguageTestScoreStatusesResponse getLanguageTestScoreStatus(SiteUser siteUser) { - // todo: ditto - SiteUser siteUser1 = siteUserRepository.findById(siteUser.getId()).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + public LanguageTestScoreStatusesResponse getLanguageTestScoreStatus(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + List languageTestScores = languageTestScoreRepository.findBySiteUserId(siteUser.getId()); + List languageTestScoreStatusResponseList = - Optional.ofNullable(siteUser1.getLanguageTestScoreList()) - .map(scores -> scores.stream() - .map(LanguageTestScoreStatusResponse::from) - .collect(Collectors.toList())) - .orElse(Collections.emptyList()); + languageTestScores.stream() + .map(LanguageTestScoreStatusResponse::from) + .collect(Collectors.toList()); + return LanguageTestScoreStatusesResponse.from(languageTestScoreStatusResponseList); } } diff --git a/src/main/java/com/example/solidconnection/custom/security/annotation/RequireAdminAccess.java b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java similarity index 56% rename from src/main/java/com/example/solidconnection/custom/security/annotation/RequireAdminAccess.java rename to src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java index 559664e25..c97ad3d51 100644 --- a/src/main/java/com/example/solidconnection/custom/security/annotation/RequireAdminAccess.java +++ b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java @@ -1,5 +1,6 @@ -package com.example.solidconnection.custom.security.annotation; +package com.example.solidconnection.security.annotation; +import com.example.solidconnection.siteuser.domain.Role; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -7,5 +8,7 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -public @interface RequireAdminAccess { +public @interface RequireRoleAccess { + + Role[] roles(); } diff --git a/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java b/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java new file mode 100644 index 000000000..55d640ed2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java @@ -0,0 +1,71 @@ +package com.example.solidconnection.security.aspect; + +import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class RoleAuthorizationAspect { + + private final SiteUserRepository siteUserRepository; + + // todo: 추후 개선 필요 + @Around("@annotation(requireRoleAccess)") + public Object checkRoleAccess(ProceedingJoinPoint joinPoint, RequireRoleAccess requireRoleAccess) throws Throwable { + + Long siteUserId = extractAuthorizedUserId(joinPoint); + + if (siteUserId == null) { + throw new CustomException(ACCESS_DENIED); + } + + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + validateUserRole(siteUser, requireRoleAccess.roles()); + + return joinPoint.proceed(); + } + + private Long extractAuthorizedUserId(ProceedingJoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Parameter[] parameters = signature.getMethod().getParameters(); + Object[] args = joinPoint.getArgs(); + + for (int i = 0; i < parameters.length; i++) { + if (parameters[i].isAnnotationPresent(AuthorizedUser.class)) { + Object arg = args[i]; + if (arg instanceof Long) { + return (Long) arg; + } else if (parameters[i].getType() == long.class) { + return (Long) arg; + } + } + } + return null; + } + + private void validateUserRole(SiteUser siteUser, Role[] allowedRoles) { + boolean hasAccess = Arrays.asList(allowedRoles).contains(siteUser.getRole()); + + if (!hasAccess) { + throw new CustomException(ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/example/solidconnection/security/authentication/TokenAuthentication.java b/src/main/java/com/example/solidconnection/security/authentication/TokenAuthentication.java new file mode 100644 index 000000000..7fe51efee --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/authentication/TokenAuthentication.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.security.authentication; + +import java.util.Collections; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; + +public class TokenAuthentication extends AbstractAuthenticationToken { + + private final Object principal; // 인증 주체 + + private final String credentials; // 증명 수단 + + public TokenAuthentication(String token) { + super(Collections.emptyList()); + this.principal = null; + this.credentials = token; + setAuthenticated(false); + } + + public TokenAuthentication(String token, Object principal) { + super(principal instanceof UserDetails ? + ((UserDetails) principal).getAuthorities() : + Collections.emptyList()); + this.principal = principal; + this.credentials = token; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + public final String getToken() { + return (String) getCredentials(); + } +} diff --git a/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java b/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java new file mode 100644 index 000000000..d0c105884 --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.security.authentication; + +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.security.userdetails.SiteUserDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TokenAuthenticationProvider implements AuthenticationProvider { + + private final SiteUserDetailsService siteUserDetailsService; + private final TokenProvider tokenProvider; + + @Override + public Authentication authenticate(Authentication auth) throws AuthenticationException { + TokenAuthentication tokenAuth = (TokenAuthentication) auth; + String token = tokenAuth.getToken(); + + String username = tokenProvider.parseSubject(token); + SiteUserDetails userDetails = (SiteUserDetails) siteUserDetailsService.loadUserByUsername(username); + return new TokenAuthentication(token, userDetails); + } + + @Override + public boolean supports(Class authentication) { + return TokenAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/example/solidconnection/security/config/AuthenticationManagerConfig.java b/src/main/java/com/example/solidconnection/security/config/AuthenticationManagerConfig.java new file mode 100644 index 000000000..68ab7cdad --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/config/AuthenticationManagerConfig.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.security.config; + +import com.example.solidconnection.security.authentication.TokenAuthenticationProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; + +@RequiredArgsConstructor +@Configuration +public class AuthenticationManagerConfig { + + private final TokenAuthenticationProvider tokenAuthenticationProvider; + + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager( + tokenAuthenticationProvider + ); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/CorsProperties.java b/src/main/java/com/example/solidconnection/security/config/CorsProperties.java similarity index 79% rename from src/main/java/com/example/solidconnection/config/security/CorsProperties.java rename to src/main/java/com/example/solidconnection/security/config/CorsProperties.java index f851692c6..af16d782c 100644 --- a/src/main/java/com/example/solidconnection/config/security/CorsProperties.java +++ b/src/main/java/com/example/solidconnection/security/config/CorsProperties.java @@ -1,9 +1,9 @@ -package com.example.solidconnection.config.security; - -import org.springframework.boot.context.properties.ConfigurationProperties; +package com.example.solidconnection.security.config; import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "cors") public record CorsProperties(List allowedOrigins) { + } diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java similarity index 79% rename from src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java rename to src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java index 1d0b110bb..706fedd52 100644 --- a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java @@ -1,10 +1,12 @@ -package com.example.solidconnection.config.security; +package com.example.solidconnection.security.config; -import com.example.solidconnection.custom.exception.CustomAccessDeniedHandler; -import com.example.solidconnection.custom.exception.CustomAuthenticationEntryPoint; -import com.example.solidconnection.custom.security.filter.ExceptionHandlerFilter; -import com.example.solidconnection.custom.security.filter.JwtAuthenticationFilter; -import com.example.solidconnection.custom.security.filter.SignOutCheckFilter; +import static com.example.solidconnection.siteuser.domain.Role.ADMIN; + +import com.example.solidconnection.common.exception.CustomAccessDeniedHandler; +import com.example.solidconnection.common.exception.CustomAuthenticationEntryPoint; +import com.example.solidconnection.security.filter.ExceptionHandlerFilter; +import com.example.solidconnection.security.filter.SignOutCheckFilter; +import com.example.solidconnection.security.filter.TokenAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,8 +22,6 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import static com.example.solidconnection.type.Role.ADMIN; - @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -30,7 +30,7 @@ public class SecurityConfiguration { private final CorsProperties corsProperties; private final ExceptionHandlerFilter exceptionHandlerFilter; private final SignOutCheckFilter signOutCheckFilter; - private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final TokenAuthenticationFilter tokenAuthenticationFilter; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; @@ -62,6 +62,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers("/connect/**").authenticated() .requestMatchers("/admin/**").hasRole(ADMIN.name()) .anyRequest().permitAll() ) @@ -69,8 +70,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authenticationEntryPoint(customAuthenticationEntryPoint) .accessDeniedHandler(customAccessDeniedHandler) ) - .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) - .addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class) + .addFilterBefore(tokenAuthenticationFilter, BasicAuthenticationFilter.class) + .addFilterBefore(signOutCheckFilter, TokenAuthenticationFilter.class) .addFilterBefore(exceptionHandlerFilter, SignOutCheckFilter.class) .build(); } diff --git a/src/main/java/com/example/solidconnection/security/filter/BlacklistChecker.java b/src/main/java/com/example/solidconnection/security/filter/BlacklistChecker.java new file mode 100644 index 000000000..f093d8e8f --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/filter/BlacklistChecker.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.security.filter; + +public interface BlacklistChecker { + + boolean isTokenBlacklisted(String token); +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java b/src/main/java/com/example/solidconnection/security/filter/ExceptionHandlerFilter.java similarity index 87% rename from src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java rename to src/main/java/com/example/solidconnection/security/filter/ExceptionHandlerFilter.java index 2db133b8f..fd6dfdbc0 100644 --- a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java +++ b/src/main/java/com/example/solidconnection/security/filter/ExceptionHandlerFilter.java @@ -1,23 +1,22 @@ -package com.example.solidconnection.custom.security.filter; +package com.example.solidconnection.security.filter; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.custom.response.ErrorResponse; +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.common.response.ErrorResponse; 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 lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; - -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; - @Component @RequiredArgsConstructor public class ExceptionHandlerFilter extends OncePerRequestFilter { diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java b/src/main/java/com/example/solidconnection/security/filter/SignOutCheckFilter.java similarity index 60% rename from src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java rename to src/main/java/com/example/solidconnection/security/filter/SignOutCheckFilter.java index 2cef8d1ac..f35a234f6 100644 --- a/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java +++ b/src/main/java/com/example/solidconnection/security/filter/SignOutCheckFilter.java @@ -1,39 +1,39 @@ -package com.example.solidconnection.custom.security.filter; +package com.example.solidconnection.security.filter; -import com.example.solidconnection.auth.service.AuthTokenProvider; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.USER_ALREADY_SIGN_OUT; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.infrastructure.AuthorizationHeaderParser; 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.Optional; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; - -import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; -import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; - @Component @RequiredArgsConstructor public class SignOutCheckFilter extends OncePerRequestFilter { - private final AuthTokenProvider authTokenProvider; + private final AuthorizationHeaderParser authorizationHeaderParser; + private final BlacklistChecker blacklistChecker; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { - String token = parseTokenFromRequest(request); - if (token != null && hasSignedOut(token)) { + Optional token = authorizationHeaderParser.parseToken(request); + if (token.isPresent() && hasSignedOut(token.get())) { throw new CustomException(USER_ALREADY_SIGN_OUT); } filterChain.doFilter(request, response); } private boolean hasSignedOut(String accessToken) { - return authTokenProvider.findBlackListToken(accessToken).isPresent(); + return blacklistChecker.isTokenBlacklisted(accessToken); } } diff --git a/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java new file mode 100644 index 000000000..8c8dc8f30 --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.security.filter; + +import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.infrastructure.AuthorizationHeaderParser; +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.Optional; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + + +@Component +@RequiredArgsConstructor +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticationManager authenticationManager; + private final AuthorizationHeaderParser authorizationHeaderParser; + + @Override + public void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + Optional resolvedToken = resolveToken(request); + + resolvedToken.filter(token -> !token.isBlank()).ifPresent(token -> { + TokenAuthentication authToken = new TokenAuthentication(token); + Authentication auth = authenticationManager.authenticate(authToken); + SecurityContextHolder.getContext().setAuthentication(auth); + }); + + filterChain.doFilter(request, response); + } + + private Optional resolveToken(HttpServletRequest request) { + if (request.getRequestURI().startsWith("/connect")) { + return Optional.ofNullable(request.getParameter("token")); + } + return authorizationHeaderParser.parseToken(request); + } +} diff --git a/src/main/java/com/example/solidconnection/security/infrastructure/AuthorizationHeaderParser.java b/src/main/java/com/example/solidconnection/security/infrastructure/AuthorizationHeaderParser.java new file mode 100644 index 000000000..0c104d58d --- /dev/null +++ b/src/main/java/com/example/solidconnection/security/infrastructure/AuthorizationHeaderParser.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.security.infrastructure; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class AuthorizationHeaderParser { + + private static final String TOKEN_HEADER = "Authorization"; + private static final String TOKEN_PREFIX = "Bearer "; + private static final int TOKEN_PREFIX_LENGTH = TOKEN_PREFIX.length(); + + public Optional parseToken(HttpServletRequest request) { + String token = request.getHeader(TOKEN_HEADER); + if (isInvalidFormat(token)) { + return Optional.empty(); + } + return Optional.of(token.substring(TOKEN_PREFIX_LENGTH)); + } + + private boolean isInvalidFormat(String token) { + return token == null || + token.isBlank() || + !token.startsWith(TOKEN_PREFIX) || + token.substring(TOKEN_PREFIX_LENGTH).isBlank(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java b/src/main/java/com/example/solidconnection/security/userdetails/SecurityRoleMapper.java similarity index 77% rename from src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java rename to src/main/java/com/example/solidconnection/security/userdetails/SecurityRoleMapper.java index 3af238f13..5183d6411 100644 --- a/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java +++ b/src/main/java/com/example/solidconnection/security/userdetails/SecurityRoleMapper.java @@ -1,9 +1,8 @@ -package com.example.solidconnection.custom.security.userdetails; - -import com.example.solidconnection.type.Role; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +package com.example.solidconnection.security.userdetails; +import com.example.solidconnection.siteuser.domain.Role; import java.util.List; +import org.springframework.security.core.authority.SimpleGrantedAuthority; public class SecurityRoleMapper { diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java b/src/main/java/com/example/solidconnection/security/userdetails/SiteUserDetails.java similarity index 95% rename from src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java rename to src/main/java/com/example/solidconnection/security/userdetails/SiteUserDetails.java index 008f77ef5..25def3518 100644 --- a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java +++ b/src/main/java/com/example/solidconnection/security/userdetails/SiteUserDetails.java @@ -1,12 +1,11 @@ -package com.example.solidconnection.custom.security.userdetails; +package com.example.solidconnection.security.userdetails; import com.example.solidconnection.siteuser.domain.SiteUser; +import java.util.Collection; import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Collection; - public class SiteUserDetails implements UserDetails { // userDetails 에서 userName 은 사용자 식별자를 의미함 diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java b/src/main/java/com/example/solidconnection/security/userdetails/SiteUserDetailsService.java similarity index 87% rename from src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java rename to src/main/java/com/example/solidconnection/security/userdetails/SiteUserDetailsService.java index fd23fa899..aa9b5dbd2 100644 --- a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java +++ b/src/main/java/com/example/solidconnection/security/userdetails/SiteUserDetailsService.java @@ -1,6 +1,9 @@ -package com.example.solidconnection.custom.security.userdetails; +package com.example.solidconnection.security.userdetails; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_TOKEN; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; @@ -9,9 +12,6 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; - @Service @RequiredArgsConstructor public class SiteUserDetailsService implements UserDetailsService { diff --git a/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java b/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java index 41862bf8b..00c3077e3 100644 --- a/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java +++ b/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java @@ -1,13 +1,17 @@ package com.example.solidconnection.siteuser.controller; -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.siteuser.domain.SiteUser; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.dto.LocationUpdateRequest; import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; import com.example.solidconnection.siteuser.service.MyPageService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -22,19 +26,37 @@ class MyPageController { @GetMapping public ResponseEntity getMyPageInfo( - @AuthorizedUser SiteUser siteUser + @AuthorizedUser long siteUserId ) { - MyPageResponse myPageResponse = myPageService.getMyPageInfo(siteUser); + MyPageResponse myPageResponse = myPageService.getMyPageInfo(siteUserId); return ResponseEntity.ok(myPageResponse); } @PatchMapping public ResponseEntity updateMyPageInfo( - @AuthorizedUser SiteUser siteUser, + @AuthorizedUser long siteUserId, @RequestParam(value = "file", required = false) MultipartFile imageFile, @RequestParam(value = "nickname", required = false) String nickname ) { - myPageService.updateMyPageInfo(siteUser, imageFile, nickname); + myPageService.updateMyPageInfo(siteUserId, imageFile, nickname); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/password") + public ResponseEntity updatePassword( + @AuthorizedUser long siteUserId, + @RequestBody @Valid PasswordUpdateRequest request + ) { + myPageService.updatePassword(siteUserId, request); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/interested-location") + public ResponseEntity updateLocation( + @AuthorizedUser long siteUserId, + @RequestBody @Valid LocationUpdateRequest request + ) { + myPageService.updateLocation(siteUserId, request); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/example/solidconnection/type/PreparationStatus.java b/src/main/java/com/example/solidconnection/siteuser/domain/ExchangeStatus.java similarity index 66% rename from src/main/java/com/example/solidconnection/type/PreparationStatus.java rename to src/main/java/com/example/solidconnection/siteuser/domain/ExchangeStatus.java index c4f1650e9..6373f8729 100644 --- a/src/main/java/com/example/solidconnection/type/PreparationStatus.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/ExchangeStatus.java @@ -1,8 +1,10 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.siteuser.domain; + +public enum ExchangeStatus { -public enum PreparationStatus { CONSIDERING, // 교환학생 지원 고민 상태 PREPARING_FOR_DEPARTURE, // 교환학생 합격 후 파견 준비 상태 STUDYING_ABROAD, // 해외 학교에서 공부중인 상태 - AFTER_EXCHANGE + AFTER_EXCHANGE, + ; } diff --git a/src/main/java/com/example/solidconnection/type/Role.java b/src/main/java/com/example/solidconnection/siteuser/domain/Role.java similarity index 52% rename from src/main/java/com/example/solidconnection/type/Role.java rename to src/main/java/com/example/solidconnection/siteuser/domain/Role.java index 8223e8de0..4ea5bf151 100644 --- a/src/main/java/com/example/solidconnection/type/Role.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/Role.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.siteuser.domain; public enum Role { diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index 21dfbcc13..9f4f7ef0f 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -1,13 +1,5 @@ package com.example.solidconnection.siteuser.domain; -import com.example.solidconnection.community.comment.domain.Comment; -import com.example.solidconnection.community.post.domain.Post; -import com.example.solidconnection.community.post.domain.PostLike; -import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -15,20 +7,16 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -37,6 +25,10 @@ @UniqueConstraint( name = "uk_site_user_email_auth_type", columnNames = {"email", "auth_type"} + ), + @UniqueConstraint( + name = "uk_site_user_nickname", + columnNames = {"nickname"} ) }) public class SiteUser { @@ -62,7 +54,7 @@ public class SiteUser { @Column(nullable = false) @Enumerated(EnumType.STRING) - private PreparationStatus preparationStage; + private ExchangeStatus exchangeStatus; @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -77,31 +69,16 @@ public class SiteUser { @Column(nullable = true) private String password; - @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) - private List postList = new ArrayList<>(); - - @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL) - private List commentList = new ArrayList<>(); - - @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) - private List postLikeList = new ArrayList<>(); - - @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) - private List languageTestScoreList = new ArrayList<>(); - - @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) - private List gpaScoreList = new ArrayList<>(); - public SiteUser( String email, String nickname, String profileImageUrl, - PreparationStatus preparationStage, + ExchangeStatus exchangeStatus, Role role) { this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; - this.preparationStage = preparationStage; + this.exchangeStatus = exchangeStatus; this.role = role; this.authType = AuthType.KAKAO; } @@ -110,13 +87,13 @@ public SiteUser( String email, String nickname, String profileImageUrl, - PreparationStatus preparationStage, + ExchangeStatus exchangeStatus, Role role, AuthType authType) { this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; - this.preparationStage = preparationStage; + this.exchangeStatus = exchangeStatus; this.role = role; this.authType = authType; } @@ -126,16 +103,20 @@ public SiteUser( String email, String nickname, String profileImageUrl, - PreparationStatus preparationStage, + ExchangeStatus exchangeStatus, Role role, AuthType authType, String password) { this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; - this.preparationStage = preparationStage; + this.exchangeStatus = exchangeStatus; this.role = role; this.authType = authType; this.password = password; } + + public void updatePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/LocationUpdateRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/LocationUpdateRequest.java new file mode 100644 index 000000000..2bb38e9cc --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/LocationUpdateRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.siteuser.dto; + +import java.util.List; + +public record LocationUpdateRequest( + List interestedRegions, + List interestedCountries +) { + +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java index 981866632..10b8b8953 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/MyPageResponse.java @@ -1,8 +1,13 @@ package com.example.solidconnection.siteuser.dto; +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; + import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.Role; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; public record MyPageResponse( String nickname, @@ -12,9 +17,17 @@ public record MyPageResponse( String email, int likedPostCount, int likedMentorCount, - int likedUniversityCount) { - public static MyPageResponse of(SiteUser siteUser, int likedUniversityCount) { + @JsonProperty("likedUniversityCount") + int likedUnivApplyInfoCount, + + @JsonInclude(NON_NULL) + List interestedCountries, + + @JsonInclude(NON_NULL) + String attendedUniversity) { + + public static MyPageResponse of(SiteUser siteUser, int likedUnivApplyInfoCount, List interestedCountries, String attendedUniversity) { return new MyPageResponse( siteUser.getNickname(), siteUser.getProfileImageUrl(), @@ -23,7 +36,9 @@ public static MyPageResponse of(SiteUser siteUser, int likedUniversityCount) { siteUser.getEmail(), 0, // TODO: 커뮤니티 기능 생기면 업데이트 필요 0, // TODO: 멘토 기능 생기면 업데이트 필요 - likedUniversityCount + likedUnivApplyInfoCount, + interestedCountries, + attendedUniversity ); } } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameExistsResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameExistsResponse.java index efb53df54..203b771e3 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameExistsResponse.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameExistsResponse.java @@ -3,6 +3,7 @@ public record NicknameExistsResponse( boolean exists ) { + public static NicknameExistsResponse from(boolean exists) { return new NicknameExistsResponse(exists); } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java index 9b83969b4..b2a8304f9 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateRequest.java @@ -6,4 +6,5 @@ public record NicknameUpdateRequest( @NotBlank(message = "닉네임을 입력해주세요.") String nickname ) { + } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java index a59e71824..a4335e670 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/NicknameUpdateResponse.java @@ -5,6 +5,7 @@ public record NicknameUpdateResponse( String nickname ) { + public static NicknameUpdateResponse from(SiteUser siteUser) { return new NicknameUpdateResponse( siteUser.getNickname() diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/PasswordUpdateRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/PasswordUpdateRequest.java new file mode 100644 index 000000000..3f3d7b077 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/PasswordUpdateRequest.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.auth.dto.validation.Password; +import com.example.solidconnection.siteuser.dto.validation.PasswordConfirmation; +import jakarta.validation.constraints.NotBlank; + +@PasswordConfirmation +public record PasswordUpdateRequest( + @NotBlank(message = "현재 비밀번호를 입력해주세요.") + String currentPassword, + + @NotBlank(message = "새 비밀번호를 입력해주세요.") + @Password + String newPassword, + + @NotBlank(message = "새 비밀번호를 다시 한번 입력해주세요.") + String newPasswordConfirmation +) { + +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java index 85d649631..048a76909 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java @@ -7,6 +7,7 @@ public record PostFindSiteUserResponse( String nickname, String profileImageUrl ) { + public static PostFindSiteUserResponse from(SiteUser siteUser) { return new PostFindSiteUserResponse( siteUser.getId(), diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java index d806fde20..3bbb33e08 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/ProfileImageUpdateResponse.java @@ -5,6 +5,7 @@ public record ProfileImageUpdateResponse( String profileImageUrl ) { + public static ProfileImageUpdateResponse from(SiteUser siteUser) { return new ProfileImageUpdateResponse( siteUser.getProfileImageUrl() diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmation.java b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmation.java new file mode 100644 index 000000000..cfc34638f --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmation.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.siteuser.dto.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = PasswordConfirmationValidator.class) +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PasswordConfirmation { + + String message() default "비밀번호 변경 과정에서 오류가 발생했습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidator.java b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidator.java new file mode 100644 index 000000000..7524e4505 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidator.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.siteuser.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CHANGED; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CONFIRMED; + +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Objects; + +public class PasswordConfirmationValidator implements ConstraintValidator { + + @Override + public boolean isValid(PasswordUpdateRequest request, ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + + if (isNewPasswordNotConfirmed(request)) { + addConstraintViolation(context, PASSWORD_NOT_CONFIRMED.getMessage(), "newPasswordConfirmation"); + + return false; + } + + if (isPasswordUnchanged(request)) { + addConstraintViolation(context, PASSWORD_NOT_CHANGED.getMessage(), "newPassword"); + + return false; + } + + return true; + } + + private boolean isNewPasswordNotConfirmed(PasswordUpdateRequest request) { + return !Objects.equals(request.newPassword(), request.newPasswordConfirmation()); + } + + private boolean isPasswordUnchanged(PasswordUpdateRequest request) { + return Objects.equals(request.currentPassword(), request.newPassword()); + } + + private void addConstraintViolation(ConstraintValidatorContext context, String message, String propertyName) { + context.buildConstraintViolationWithTemplate(message) + .addPropertyNode(propertyName) + .addConstraintViolation(); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java deleted file mode 100644 index d15949723..000000000 --- a/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.solidconnection.siteuser.repository; - -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.university.domain.LikedUniversity; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface LikedUniversityRepository extends JpaRepository { - - List findAllBySiteUser_Id(long siteUserId); - - int countBySiteUser_Id(long siteUserId); - - Optional findBySiteUserAndUniversityInfoForApply(SiteUser siteUser, UniversityInfoForApply universityInfoForApply); -} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java index e0617f046..864e72ed3 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -2,16 +2,13 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - import java.time.LocalDate; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -@Repository public interface SiteUserRepository extends JpaRepository { Optional findByEmailAndAuthType(String email, AuthType authType); diff --git a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java index e89c6cdfa..d48de9bfa 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java @@ -1,27 +1,39 @@ package com.example.solidconnection.siteuser.service; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH; +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.location.country.service.InterestedCountryService; +import com.example.solidconnection.location.region.service.InterestedRegionService; +import com.example.solidconnection.location.country.repository.CountryRepository; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.LocationUpdateRequest; import com.example.solidconnection.siteuser.dto.MyPageResponse; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.university.domain.LikedUniversity; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; +import com.example.solidconnection.university.repository.UniversityRepository; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; -import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; - @RequiredArgsConstructor @Service public class MyPageService { @@ -29,45 +41,61 @@ public class MyPageService { public static final int MIN_DAYS_BETWEEN_NICKNAME_CHANGES = 7; public static final DateTimeFormatter NICKNAME_LAST_CHANGE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private final PasswordEncoder passwordEncoder; private final SiteUserRepository siteUserRepository; - private final LikedUniversityRepository likedUniversityRepository; + private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; + private final CountryRepository countryRepository; + private final MentorRepository mentorRepository; + private final UniversityRepository universityRepository; private final S3Service s3Service; + private final InterestedCountryService interestedCountryService; + private final InterestedRegionService interestedRegionService; /* * 마이페이지 정보를 조회한다. * */ @Transactional(readOnly = true) - public MyPageResponse getMyPageInfo(SiteUser siteUser) { - int likedUniversityCount = likedUniversityRepository.countBySiteUser_Id(siteUser.getId()); - return MyPageResponse.of(siteUser, likedUniversityCount); + public MyPageResponse getMyPageInfo(long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + int likedUnivApplyInfoCount = likedUnivApplyInfoRepository.countBySiteUserId(siteUser.getId()); + + List interestedCountries = null; + String universityKoreanName = null; + if (siteUser.getRole() == Role.MENTEE) { + interestedCountries = countryRepository.findKoreanNamesBySiteUserId(siteUser.getId()); + } else if (siteUser.getRole() == Role.MENTOR) { + Mentor mentor = mentorRepository.findBySiteUserId(siteUser.getId()) + .orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND)); + University university = universityRepository.findById(mentor.getUniversityId()) + .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + universityKoreanName = university.getKoreanName(); + } + return MyPageResponse.of(siteUser, likedUnivApplyInfoCount, interestedCountries, universityKoreanName); } /* * 마이페이지 정보를 수정한다. * */ @Transactional - public void updateMyPageInfo(SiteUser siteUser, MultipartFile imageFile, String nickname) { + public void updateMyPageInfo(long siteUserId, MultipartFile imageFile, String nickname) { + SiteUser user = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + if (nickname != null) { + validateNicknameNotChangedRecently(user.getNicknameModifiedAt()); validateNicknameUnique(nickname); - validateNicknameNotChangedRecently(siteUser.getNicknameModifiedAt()); - siteUser.setNickname(nickname); - siteUser.setNicknameModifiedAt(LocalDateTime.now()); + user.setNickname(nickname); + user.setNicknameModifiedAt(LocalDateTime.now()); } if (imageFile != null && !imageFile.isEmpty()) { UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.PROFILE); - if (!isDefaultProfileImage(siteUser.getProfileImageUrl())) { - s3Service.deleteExProfile(siteUser); + if (!isDefaultProfileImage(user.getProfileImageUrl())) { + s3Service.deleteExProfile(user.getId()); } String profileImageUrl = uploadedFile.fileUrl(); - siteUser.setProfileImageUrl(profileImageUrl); - } - siteUserRepository.save(siteUser); - } - - private void validateNicknameUnique(String nickname) { - if (siteUserRepository.existsByNickname(nickname)) { - throw new CustomException(NICKNAME_ALREADY_EXISTED); + user.setProfileImageUrl(profileImageUrl); } } @@ -82,19 +110,40 @@ private void validateNicknameNotChangedRecently(LocalDateTime lastModifiedAt) { } } + private void validateNicknameUnique(String nickname) { + if (siteUserRepository.existsByNickname(nickname)) { + throw new CustomException(NICKNAME_ALREADY_EXISTED); + } + } + private boolean isDefaultProfileImage(String profileImageUrl) { String prefix = "profile/"; return profileImageUrl == null || !profileImageUrl.startsWith(prefix); } - /* - * 관심 대학교 목록을 조회한다. - * */ - @Transactional(readOnly = true) - public List getWishUniversity(SiteUser siteUser) { - List likedUniversities = likedUniversityRepository.findAllBySiteUser_Id(siteUser.getId()); - return likedUniversities.stream() - .map(likedUniversity -> UniversityInfoForApplyPreviewResponse.from(likedUniversity.getUniversityInfoForApply())) - .toList(); + @Transactional + public void updatePassword(long siteUserId, PasswordUpdateRequest request) { + SiteUser user = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + // 사용자의 비밀번호와 request의 currentPassword가 동일한지 검증 + validatePasswordMatch(request.currentPassword(), user.getPassword()); + + user.updatePassword(passwordEncoder.encode(request.newPassword())); + } + + private void validatePasswordMatch(String currentPassword, String userPassword) { + if (!passwordEncoder.matches(currentPassword, userPassword)) { + throw new CustomException(PASSWORD_MISMATCH); + } + } + + @Transactional + public void updateLocation(long siteUserId, LocationUpdateRequest request) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + interestedCountryService.updateInterestedCountry(siteUser, request.interestedCountries()); + interestedRegionService.updateInterestedRegion(siteUser, request.interestedRegions()); } } diff --git a/src/main/java/com/example/solidconnection/type/PostCategory.java b/src/main/java/com/example/solidconnection/type/PostCategory.java deleted file mode 100644 index b42b94f95..000000000 --- a/src/main/java/com/example/solidconnection/type/PostCategory.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.solidconnection.type; - -public enum PostCategory { - 전체, 자유, 질문 -} diff --git a/src/main/java/com/example/solidconnection/type/VerifyStatus.java b/src/main/java/com/example/solidconnection/type/VerifyStatus.java deleted file mode 100644 index 95f122715..000000000 --- a/src/main/java/com/example/solidconnection/type/VerifyStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.solidconnection.type; - -public enum VerifyStatus { - PENDING, REJECTED, APPROVED -} diff --git a/src/main/java/com/example/solidconnection/university/controller/UnivApplyInfoController.java b/src/main/java/com/example/solidconnection/university/controller/UnivApplyInfoController.java new file mode 100644 index 000000000..fab050079 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/controller/UnivApplyInfoController.java @@ -0,0 +1,109 @@ +package com.example.solidconnection.university.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoDetailResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoFilterSearchRequest; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponses; +import com.example.solidconnection.university.dto.UnivApplyInfoRecommendsResponse; +import com.example.solidconnection.university.service.LikedUnivApplyInfoService; +import com.example.solidconnection.university.service.UnivApplyInfoQueryService; +import com.example.solidconnection.university.service.UnivApplyInfoRecommendService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +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.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/univ-apply-infos") +@RestController +public class UnivApplyInfoController { + + private final UnivApplyInfoQueryService univApplyInfoQueryService; + private final LikedUnivApplyInfoService likedUnivApplyInfoService; + private final UnivApplyInfoRecommendService univApplyInfoRecommendService; + + @Value("${university.term}") + public String term; + + @GetMapping("/recommend") + public ResponseEntity getUnivApplyInfoRecommends( + @AuthorizedUser(required = false) Long siteUserId + ) { + if (siteUserId == null) { + return ResponseEntity.ok(univApplyInfoRecommendService.getGeneralRecommends()); + } else { + return ResponseEntity.ok(univApplyInfoRecommendService.getPersonalRecommends(siteUserId)); + } + } + + // todo: return 타입 UnivApplyInfoPreviewResponses 같이 객체로 묶어서 반환하는 것으로 변경 필요 + @GetMapping("/like") + public ResponseEntity> getLikedUnivApplyInfos( + @AuthorizedUser long siteUserId + ) { + List likedUnivApplyInfos = likedUnivApplyInfoService.getLikedUnivApplyInfos(siteUserId); + return ResponseEntity.ok(likedUnivApplyInfos); + } + + @GetMapping("/{univ-apply-info-id}/like") + public ResponseEntity isUnivApplyInfoLiked( + @AuthorizedUser long siteUserId, + @PathVariable("univ-apply-info-id") Long univApplyInfoId + ) { + IsLikeResponse isLiked = likedUnivApplyInfoService.isUnivApplyInfoLiked(siteUserId, univApplyInfoId); + return ResponseEntity.ok(isLiked); + } + + @PostMapping("/{univ-apply-info-id}/like") + public ResponseEntity addUnivApplyInfoLike( + @AuthorizedUser long siteUserId, + @PathVariable("univ-apply-info-id") Long univApplyInfoId + ) { + likedUnivApplyInfoService.addUnivApplyInfoLike(siteUserId, univApplyInfoId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{univ-apply-info-id}/like") + public ResponseEntity cancelUnivApplyInfoLike( + @AuthorizedUser long siteUserId, + @PathVariable("univ-apply-info-id") Long univApplyInfoId + ) { + likedUnivApplyInfoService.cancelUnivApplyInfoLike(siteUserId, univApplyInfoId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{univ-apply-info-id}") + public ResponseEntity getUnivApplyInfoDetails( + @PathVariable("univ-apply-info-id") Long univApplyInfoId + ) { + UnivApplyInfoDetailResponse univApplyInfoDetailResponse = univApplyInfoQueryService.getUnivApplyInfoDetail(univApplyInfoId); + return ResponseEntity.ok(univApplyInfoDetailResponse); + } + + @GetMapping("/search/filter") + public ResponseEntity searchUnivApplyInfoByFilter( + @Valid @ModelAttribute UnivApplyInfoFilterSearchRequest request + ) { + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request, term); + return ResponseEntity.ok(response); + } + + @GetMapping("/search/text") + public ResponseEntity searchUnivApplyInfoByText( + @RequestParam(required = false) String value + ) { + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(value, term); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java deleted file mode 100644 index 635693d4c..000000000 --- a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.example.solidconnection.university.controller; - -import com.example.solidconnection.custom.resolver.AuthorizedUser; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.service.MyPageService; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.dto.IsLikeResponse; -import com.example.solidconnection.university.dto.LikeResultResponse; -import com.example.solidconnection.university.dto.UniversityDetailResponse; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import com.example.solidconnection.university.dto.UniversityRecommendsResponse; -import com.example.solidconnection.university.service.UniversityLikeService; -import com.example.solidconnection.university.service.UniversityQueryService; -import com.example.solidconnection.university.service.UniversityRecommendService; -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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@RequiredArgsConstructor -@RequestMapping("/universities") -@RestController -public class UniversityController { - - private final UniversityQueryService universityQueryService; - private final UniversityLikeService universityLikeService; - private final UniversityRecommendService universityRecommendService; - private final MyPageService myPageService; - - @GetMapping("/recommend") - public ResponseEntity getUniversityRecommends( - @AuthorizedUser(required = false) SiteUser siteUser - ) { - if (siteUser == null) { - return ResponseEntity.ok(universityRecommendService.getGeneralRecommends()); - } else { - return ResponseEntity.ok(universityRecommendService.getPersonalRecommends(siteUser)); - } - } - - @GetMapping("/like") - public ResponseEntity> getMyWishUniversity( - @AuthorizedUser SiteUser siteUser - ) { - List wishUniversities = myPageService.getWishUniversity(siteUser); - return ResponseEntity.ok(wishUniversities); - } - - @GetMapping("/{universityInfoForApplyId}/like") - public ResponseEntity getIsLiked( - @AuthorizedUser SiteUser siteUser, - @PathVariable Long universityInfoForApplyId - ) { - IsLikeResponse isLiked = universityLikeService.getIsLiked(siteUser, universityInfoForApplyId); - return ResponseEntity.ok(isLiked); - } - - @PostMapping("/{universityInfoForApplyId}/like") - public ResponseEntity addWishUniversity( - @AuthorizedUser SiteUser siteUser, - @PathVariable Long universityInfoForApplyId - ) { - LikeResultResponse likeResultResponse = universityLikeService.likeUniversity(siteUser, universityInfoForApplyId); - return ResponseEntity.ok(likeResultResponse); - } - - @DeleteMapping("/{universityInfoForApplyId}/like") - public ResponseEntity cancelWishUniversity( - @AuthorizedUser SiteUser siteUser, - @PathVariable Long universityInfoForApplyId - ) { - LikeResultResponse likeResultResponse = universityLikeService.cancelLikeUniversity(siteUser, universityInfoForApplyId); - return ResponseEntity.ok(likeResultResponse); - } - - @GetMapping("/{universityInfoForApplyId}") - public ResponseEntity getUniversityDetails( - @PathVariable Long universityInfoForApplyId - ) { - UniversityDetailResponse universityDetailResponse = universityQueryService.getUniversityDetail(universityInfoForApplyId); - return ResponseEntity.ok(universityDetailResponse); - } - - // todo return타입 UniversityInfoForApplyPreviewResponses로 추후 수정 필요 - @GetMapping("/search") - public ResponseEntity> searchUniversity( - @RequestParam(required = false, defaultValue = "") String region, - @RequestParam(required = false, defaultValue = "") List keyword, - @RequestParam(required = false, defaultValue = "") LanguageTestType testType, - @RequestParam(required = false, defaultValue = "") String testScore - ) { - List universityInfoForApplyPreviewResponse - = universityQueryService.searchUniversity(region, keyword, testType, testScore).universityInfoForApplyPreviewResponses(); - return ResponseEntity.ok(universityInfoForApplyPreviewResponse); - } -} diff --git a/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java b/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java index 508c7531e..7cca13d9e 100644 --- a/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java +++ b/src/main/java/com/example/solidconnection/university/domain/LanguageRequirement.java @@ -1,6 +1,5 @@ package com.example.solidconnection.university.domain; -import com.example.solidconnection.type.LanguageTestType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -9,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -16,7 +16,7 @@ import lombok.NoArgsConstructor; @Getter -@AllArgsConstructor(access = AccessLevel.PUBLIC) +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class LanguageRequirement { @@ -33,5 +33,6 @@ public class LanguageRequirement { private String minScore; @ManyToOne(fetch = FetchType.LAZY) - private UniversityInfoForApply universityInfoForApply; + @JoinColumn(name = "university_info_for_apply_id") + private UnivApplyInfo univApplyInfo; } diff --git a/src/main/java/com/example/solidconnection/type/LanguageTestType.java b/src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java similarity index 87% rename from src/main/java/com/example/solidconnection/type/LanguageTestType.java rename to src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java index 29082c98e..af0f861db 100644 --- a/src/main/java/com/example/solidconnection/type/LanguageTestType.java +++ b/src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java @@ -1,11 +1,11 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.university.domain; import java.util.Comparator; public enum LanguageTestType { - CEFR((s1, s2) -> s1.compareTo(s2)), - JLPT((s1, s2) -> s2.compareTo(s1)), + CEFR(String::compareTo), + JLPT(Comparator.reverseOrder()), DALF(LanguageTestType::compareIntegerScores), DELF(LanguageTestType::compareIntegerScores), DUOLINGO(LanguageTestType::compareIntegerScores), @@ -16,7 +16,7 @@ public enum LanguageTestType { TOEFL_IBT(LanguageTestType::compareIntegerScores), TOEFL_ITP(LanguageTestType::compareIntegerScores), TOEIC(LanguageTestType::compareIntegerScores), - ETC((s1, s2) -> 0), // 기타 언어시험은 점수를 비교할 수 없으므로 항상 크다고 비교한다. + ETC((s1, s2) -> 0), // 기타 언어시험은 점수를 비교할 수 없으므로 항상 같다고 비교한다. ; private final Comparator comparator; diff --git a/src/main/java/com/example/solidconnection/university/domain/LikedUnivApplyInfo.java b/src/main/java/com/example/solidconnection/university/domain/LikedUnivApplyInfo.java new file mode 100644 index 000000000..b1798f205 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/LikedUnivApplyInfo.java @@ -0,0 +1,39 @@ +package com.example.solidconnection.university.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table( + name = "liked_university_info_for_apply", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_liked_university_site_user_id_university_info_for_apply_id", + columnNames = {"site_user_id", "university_info_for_apply_id"} + ) + }) +public class LikedUnivApplyInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "university_info_for_apply_id") + private long univApplyInfoId; + + @Column(name = "site_user_id") + private long siteUserId; +} diff --git a/src/main/java/com/example/solidconnection/university/domain/LikedUniversity.java b/src/main/java/com/example/solidconnection/university/domain/LikedUniversity.java deleted file mode 100644 index ad7ee02c8..000000000 --- a/src/main/java/com/example/solidconnection/university/domain/LikedUniversity.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example.solidconnection.university.domain; - -import com.example.solidconnection.siteuser.domain.SiteUser; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class LikedUniversity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - private UniversityInfoForApply universityInfoForApply; - - @ManyToOne - private SiteUser siteUser; -} diff --git a/src/main/java/com/example/solidconnection/type/SemesterAvailableForDispatch.java b/src/main/java/com/example/solidconnection/university/domain/SemesterAvailableForDispatch.java similarity index 90% rename from src/main/java/com/example/solidconnection/type/SemesterAvailableForDispatch.java rename to src/main/java/com/example/solidconnection/university/domain/SemesterAvailableForDispatch.java index 2a04805d6..9d44ecd8c 100644 --- a/src/main/java/com/example/solidconnection/type/SemesterAvailableForDispatch.java +++ b/src/main/java/com/example/solidconnection/university/domain/SemesterAvailableForDispatch.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.university.domain; public enum SemesterAvailableForDispatch { ONE_SEMESTER("1개학기"), diff --git a/src/main/java/com/example/solidconnection/type/TuitionFeeType.java b/src/main/java/com/example/solidconnection/university/domain/TuitionFeeType.java similarity index 87% rename from src/main/java/com/example/solidconnection/type/TuitionFeeType.java rename to src/main/java/com/example/solidconnection/university/domain/TuitionFeeType.java index 21ab6700e..c7abc09e1 100644 --- a/src/main/java/com/example/solidconnection/type/TuitionFeeType.java +++ b/src/main/java/com/example/solidconnection/university/domain/TuitionFeeType.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.type; +package com.example.solidconnection.university.domain; public enum TuitionFeeType { HOME_UNIVERSITY_PAYMENT("본교등록금납부형"), diff --git a/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java similarity index 86% rename from src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java rename to src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java index e1a87fe83..09db466bd 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java @@ -1,7 +1,6 @@ package com.example.solidconnection.university.domain; -import com.example.solidconnection.type.SemesterAvailableForDispatch; -import com.example.solidconnection.type.TuitionFeeType; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -12,21 +11,22 @@ import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Set; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.HashSet; -import java.util.Set; - @Getter @EqualsAndHashCode(of = "id") -@AllArgsConstructor(access = AccessLevel.PUBLIC) +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -public class UniversityInfoForApply { +@Table(name = "university_info_for_apply") +public class UnivApplyInfo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -76,10 +76,10 @@ public class UniversityInfoForApply { @Column(length = 1000) private String details; - @OneToMany(mappedBy = "universityInfoForApply", fetch = FetchType.EAGER) + @OneToMany(mappedBy = "univApplyInfo", cascade = CascadeType.ALL, orphanRemoval = true) private Set languageRequirements = new HashSet<>(); - @ManyToOne(fetch = FetchType.EAGER) + @ManyToOne(fetch = FetchType.LAZY) private University university; public void addLanguageRequirements(LanguageRequirement languageRequirements) { diff --git a/src/main/java/com/example/solidconnection/university/domain/University.java b/src/main/java/com/example/solidconnection/university/domain/University.java index c3021385e..e3738ce0f 100644 --- a/src/main/java/com/example/solidconnection/university/domain/University.java +++ b/src/main/java/com/example/solidconnection/university/domain/University.java @@ -1,7 +1,7 @@ package com.example.solidconnection.university.domain; -import com.example.solidconnection.entity.Country; -import com.example.solidconnection.entity.Region; +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.region.domain.Region; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -14,7 +14,7 @@ import lombok.NoArgsConstructor; @Entity -@AllArgsConstructor(access = AccessLevel.PUBLIC) +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class University { diff --git a/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java b/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java index 7d4aebbf9..b0d340649 100644 --- a/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java +++ b/src/main/java/com/example/solidconnection/university/dto/IsLikeResponse.java @@ -2,4 +2,5 @@ public record IsLikeResponse( boolean isLike) { + } diff --git a/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java b/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java index 8cc7b9733..292bd837d 100644 --- a/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java +++ b/src/main/java/com/example/solidconnection/university/dto/LanguageRequirementResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.university.dto; -import com.example.solidconnection.type.LanguageTestType; import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.LanguageTestType; public record LanguageRequirementResponse( LanguageTestType languageTestType, diff --git a/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java b/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java index c67f2e408..11f01c180 100644 --- a/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java +++ b/src/main/java/com/example/solidconnection/university/dto/LikeResultResponse.java @@ -2,4 +2,5 @@ public record LikeResultResponse( String result) { + } diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityDetailResponse.java b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java similarity index 55% rename from src/main/java/com/example/solidconnection/university/dto/UniversityDetailResponse.java rename to src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java index 4121654a3..481629f24 100644 --- a/src/main/java/com/example/solidconnection/university/dto/UniversityDetailResponse.java +++ b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java @@ -1,11 +1,10 @@ package com.example.solidconnection.university.dto; +import com.example.solidconnection.university.domain.UnivApplyInfo; import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; - import java.util.List; -public record UniversityDetailResponse( +public record UnivApplyInfoDetailResponse( long id, String term, String koreanName, @@ -33,13 +32,13 @@ public record UniversityDetailResponse( String accommodationUrl, String englishCourseUrl) { - public static UniversityDetailResponse of( + public static UnivApplyInfoDetailResponse of( University university, - UniversityInfoForApply universityInfoForApply) { - return new UniversityDetailResponse( - universityInfoForApply.getId(), - universityInfoForApply.getTerm(), - universityInfoForApply.getKoreanName(), + UnivApplyInfo univApplyInfo) { + return new UnivApplyInfoDetailResponse( + univApplyInfo.getId(), + univApplyInfo.getTerm(), + univApplyInfo.getKoreanName(), university.getEnglishName(), university.getFormatName(), university.getRegion().getKoreanName(), @@ -48,21 +47,21 @@ public static UniversityDetailResponse of( university.getLogoImageUrl(), university.getBackgroundImageUrl(), university.getDetailsForLocal(), - universityInfoForApply.getStudentCapacity(), - universityInfoForApply.getTuitionFeeType().getKoreanName(), - universityInfoForApply.getSemesterAvailableForDispatch().getKoreanName(), - universityInfoForApply.getLanguageRequirements().stream() + univApplyInfo.getStudentCapacity(), + univApplyInfo.getTuitionFeeType().getKoreanName(), + univApplyInfo.getSemesterAvailableForDispatch().getKoreanName(), + univApplyInfo.getLanguageRequirements().stream() .map(LanguageRequirementResponse::from) .toList(), - universityInfoForApply.getDetailsForLanguage(), - universityInfoForApply.getGpaRequirement(), - universityInfoForApply.getGpaRequirementCriteria(), - universityInfoForApply.getSemesterRequirement(), - universityInfoForApply.getDetailsForApply(), - universityInfoForApply.getDetailsForMajor(), - universityInfoForApply.getDetailsForAccommodation(), - universityInfoForApply.getDetailsForEnglishCourse(), - universityInfoForApply.getDetails(), + univApplyInfo.getDetailsForLanguage(), + univApplyInfo.getGpaRequirement(), + univApplyInfo.getGpaRequirementCriteria(), + univApplyInfo.getSemesterRequirement(), + univApplyInfo.getDetailsForApply(), + univApplyInfo.getDetailsForMajor(), + univApplyInfo.getDetailsForAccommodation(), + univApplyInfo.getDetailsForEnglishCourse(), + univApplyInfo.getDetails(), university.getAccommodationUrl(), university.getEnglishCourseUrl() ); diff --git a/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoFilterSearchRequest.java b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoFilterSearchRequest.java new file mode 100644 index 000000000..a49079319 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoFilterSearchRequest.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.university.dto; + +import com.example.solidconnection.university.domain.LanguageTestType; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record UnivApplyInfoFilterSearchRequest( + + @NotNull(message = "어학 시험 종류를 선택해주세요.") + LanguageTestType languageTestType, + String testScore, + List countryCode +) { + +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoPreviewResponse.java b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoPreviewResponse.java new file mode 100644 index 000000000..0c6badbfd --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoPreviewResponse.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.university.dto; + +import com.example.solidconnection.university.domain.UnivApplyInfo; +import java.util.Collections; +import java.util.List; + +public record UnivApplyInfoPreviewResponse( + long id, + String term, + String koreanName, + String region, + String country, + String logoImageUrl, + String backgroundImageUrl, + int studentCapacity, + List languageRequirements) { + + public static UnivApplyInfoPreviewResponse from(UnivApplyInfo univApplyInfo) { + List languageRequirementResponses = new java.util.ArrayList<>( + univApplyInfo.getLanguageRequirements().stream() + .map(LanguageRequirementResponse::from) + .toList()); + Collections.sort(languageRequirementResponses); + + return new UnivApplyInfoPreviewResponse( + univApplyInfo.getId(), + univApplyInfo.getTerm(), + univApplyInfo.getKoreanName(), + univApplyInfo.getUniversity().getRegion().getKoreanName(), + univApplyInfo.getUniversity().getCountry().getKoreanName(), + univApplyInfo.getUniversity().getLogoImageUrl(), + univApplyInfo.getUniversity().getBackgroundImageUrl(), + univApplyInfo.getStudentCapacity(), + languageRequirementResponses + ); + } +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoPreviewResponses.java b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoPreviewResponses.java new file mode 100644 index 000000000..610404555 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoPreviewResponses.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.university.dto; + +import java.util.List; + +public record UnivApplyInfoPreviewResponses( + List univApplyInfoPreviews + // todo: #345 응답 형식으로 바로 배열이 아니라, univApplyInfoPreviews로 감싸 응답한다고 전달 후, 코드 변경 필요 +) { + +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoRecommendsResponse.java b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoRecommendsResponse.java new file mode 100644 index 000000000..759f1d813 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoRecommendsResponse.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.university.dto; + +import java.util.List; + +public record UnivApplyInfoRecommendsResponse( + List recommendedUniversities) { + // todo: #345 프론트에 recommendedUnivApplyInfos 로 응답한다고 전달 후, 인자명 변경 필요 +} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java deleted file mode 100644 index f6c2b4969..000000000 --- a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.example.solidconnection.university.dto; - -import com.example.solidconnection.university.domain.UniversityInfoForApply; - -import java.util.Collections; -import java.util.List; - -public record UniversityInfoForApplyPreviewResponse( - long id, - String term, - String koreanName, - String region, - String country, - String logoImageUrl, - String backgroundImageUrl, - int studentCapacity, - List languageRequirements) { - - public static UniversityInfoForApplyPreviewResponse from(UniversityInfoForApply universityInfoForApply) { - List languageRequirementResponses = new java.util.ArrayList<>( - universityInfoForApply.getLanguageRequirements().stream() - .map(LanguageRequirementResponse::from) - .toList()); - Collections.sort(languageRequirementResponses); - - return new UniversityInfoForApplyPreviewResponse( - universityInfoForApply.getId(), - universityInfoForApply.getTerm(), - universityInfoForApply.getKoreanName(), - universityInfoForApply.getUniversity().getRegion().getKoreanName(), - universityInfoForApply.getUniversity().getCountry().getKoreanName(), - universityInfoForApply.getUniversity().getLogoImageUrl(), - universityInfoForApply.getUniversity().getBackgroundImageUrl(), - universityInfoForApply.getStudentCapacity(), - languageRequirementResponses - ); - } -} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java deleted file mode 100644 index 3c8a00df4..000000000 --- a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.solidconnection.university.dto; - -import java.util.List; - -public record UniversityInfoForApplyPreviewResponses( - List universityInfoForApplyPreviewResponses -) { -} diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityRecommendsResponse.java b/src/main/java/com/example/solidconnection/university/dto/UniversityRecommendsResponse.java deleted file mode 100644 index 057061f3e..000000000 --- a/src/main/java/com/example/solidconnection/university/dto/UniversityRecommendsResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.solidconnection.university.dto; - -import java.util.List; - -public record UniversityRecommendsResponse( - List recommendedUniversities) { -} diff --git a/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java b/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoice.java similarity index 63% rename from src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java rename to src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoice.java index 7e5827113..4cef2b5eb 100644 --- a/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java +++ b/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoice.java @@ -1,9 +1,7 @@ -package com.example.solidconnection.custom.validation.annotation; +package com.example.solidconnection.university.dto.validation; -import com.example.solidconnection.custom.validation.validator.ValidUniversityChoiceValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -11,10 +9,12 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) -@Constraint(validatedBy = ValidUniversityChoiceValidator.class) -public @interface ValidUniversityChoice { +@Constraint(validatedBy = ValidUnivApplyInfoChoiceValidator.class) +public @interface ValidUnivApplyInfoChoice { String message() default "유효하지 않은 지망 대학 선택입니다."; + Class[] groups() default {}; + Class[] payload() default {}; } diff --git a/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java b/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java new file mode 100644 index 000000000..500695646 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java @@ -0,0 +1,60 @@ +package com.example.solidconnection.university.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_UNIV_APPLY_INFO_CHOICE; +import static com.example.solidconnection.common.exception.ErrorCode.FIRST_CHOICE_REQUIRED; +import static com.example.solidconnection.common.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; + +import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +public class ValidUnivApplyInfoChoiceValidator implements ConstraintValidator { + + @Override + public boolean isValid(UnivApplyInfoChoiceRequest request, ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + + if (isFirstChoiceNotSelected(request)) { + context.buildConstraintViolationWithTemplate(FIRST_CHOICE_REQUIRED.getMessage()) + .addConstraintViolation(); + return false; + } + + if (isThirdChoiceWithoutSecond(request)) { + context.buildConstraintViolationWithTemplate(THIRD_CHOICE_REQUIRES_SECOND.getMessage()) + .addConstraintViolation(); + return false; + } + + if (isDuplicate(request)) { + context.buildConstraintViolationWithTemplate(DUPLICATE_UNIV_APPLY_INFO_CHOICE.getMessage()) + .addConstraintViolation(); + return false; + } + + return true; + } + + private boolean isFirstChoiceNotSelected(UnivApplyInfoChoiceRequest request) { + return request.firstChoiceUnivApplyInfoId() == null; + } + + private boolean isThirdChoiceWithoutSecond(UnivApplyInfoChoiceRequest request) { + return request.thirdChoiceUnivApplyInfoId() != null && request.secondChoiceUnivApplyInfoId() == null; + } + + private boolean isDuplicate(UnivApplyInfoChoiceRequest request) { + Set uniqueIds = new HashSet<>(); + return Stream.of( + request.firstChoiceUnivApplyInfoId(), + request.secondChoiceUnivApplyInfoId(), + request.thirdChoiceUnivApplyInfoId() + ) + .filter(Objects::nonNull) + .anyMatch(id -> !uniqueIds.add(id)); + } +} diff --git a/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java b/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java index 4cbebc6f5..edaf4c3a8 100644 --- a/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/LanguageRequirementRepository.java @@ -1,18 +1,8 @@ package com.example.solidconnection.university.repository; -import com.example.solidconnection.type.LanguageTestType; import com.example.solidconnection.university.domain.LanguageRequirement; -import com.example.solidconnection.university.domain.UniversityInfoForApply; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; -import java.util.Optional; - -@Repository public interface LanguageRequirementRepository extends JpaRepository { - @Query("SELECT lr FROM LanguageRequirement lr WHERE lr.minScore <= :myScore AND lr.languageTestType = :testType AND lr.universityInfoForApply = :universityInfoForApply ORDER BY lr.minScore ASC") - Optional findByUniversityInfoForApplyAndLanguageTestTypeAndLessThanMyScore(@Param("universityInfoForApply") UniversityInfoForApply universityInfoForApply, @Param("testType") LanguageTestType testType, @Param("myScore") String myScore); } diff --git a/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java b/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java new file mode 100644 index 000000000..0dc8255a6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.university.repository; + +import com.example.solidconnection.university.domain.LikedUnivApplyInfo; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface LikedUnivApplyInfoRepository extends JpaRepository { + + List findAllBySiteUserId(long siteUserId); + + int countBySiteUserId(long siteUserId); + + Optional findBySiteUserIdAndUnivApplyInfoId(long siteUserId, long univApplyInfoId); + + @Query(""" + SELECT u + FROM UnivApplyInfo u + JOIN LikedUnivApplyInfo l ON u.id = l.univApplyInfoId + WHERE l.siteUserId = :siteUserId + """) + List findUnivApplyInfosBySiteUserId(@Param("siteUserId") long siteUserId); + + boolean existsBySiteUserIdAndUnivApplyInfoId(long siteUserId, long univApplyInfoId); +} diff --git a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java new file mode 100644 index 000000000..b51ce5a89 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java @@ -0,0 +1,66 @@ +package com.example.solidconnection.university.repository; + +import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.custom.UnivApplyInfoFilterRepository; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface UnivApplyInfoRepository extends JpaRepository, UnivApplyInfoFilterRepository { + + @Query(""" + SELECT DISTINCT uai + FROM UnivApplyInfo uai + LEFT JOIN FETCH uai.languageRequirements lr + JOIN FETCH uai.university u + LEFT JOIN FETCH u.country c + LEFT JOIN FETCH u.region r + WHERE (c.code IN ( + SELECT ic.countryCode + FROM InterestedCountry ic + WHERE ic.siteUserId = :siteUserId + ) + OR r.code IN ( + SELECT ir.regionCode + FROM InterestedRegion ir + WHERE ir.siteUserId = :siteUserId + )) + AND uai.term = :term + """) + List findAllBySiteUsersInterestedCountryOrRegionAndTerm(@Param("siteUserId") Long siteUserId, @Param("term") String term); + + @Query(""" + SELECT uai + FROM UnivApplyInfo uai + LEFT JOIN FETCH uai.languageRequirements lr + LEFT JOIN FETCH uai.university u + LEFT JOIN FETCH u.country c + LEFT JOIN FETCH u.region r + WHERE uai.term = :term + ORDER BY FUNCTION('RAND') + """) + List findRandomByTerm(@Param("term") String term, Pageable pageable); // JPA에서 LIMIT 사용이 불가하므로 Pageable을 통해 0page에서 정해진 개수 만큼 가져오는 방식으로 구현 + + default UnivApplyInfo getUnivApplyInfoById(Long id) { + return findById(id) + .orElseThrow(() -> new CustomException(UNIV_APPLY_INFO_NOT_FOUND)); + } + + @Query(""" + SELECT DISTINCT uai + FROM UnivApplyInfo uai + LEFT JOIN FETCH uai.languageRequirements lr + LEFT JOIN FETCH uai.university u + LEFT JOIN FETCH u.country c + LEFT JOIN FETCH u.region r + WHERE uai.id IN :ids + """) + List findAllByIds(@Param("ids") List ids); +} diff --git a/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java b/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java deleted file mode 100644 index 60474c13d..000000000 --- a/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.example.solidconnection.university.repository; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; -import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM; - -@Repository -public interface UniversityInfoForApplyRepository extends JpaRepository { - - Optional findByIdAndTerm(Long id, String term); - - Optional findFirstByKoreanNameAndTerm(String koreanName, String term); - - @Query("SELECT c FROM UniversityInfoForApply c WHERE c.university IN :universities AND c.term = :term") - List findByUniversitiesAndTerm(@Param("universities") List universities, @Param("term") String term); - - @Query(""" - SELECT uifa - FROM UniversityInfoForApply uifa - JOIN University u ON uifa.university = u - WHERE (u.country.code IN ( - SELECT c.code - FROM InterestedCountry ic - JOIN ic.country c - WHERE ic.siteUser = :siteUser - ) - OR u.region.code IN ( - SELECT r.code - FROM InterestedRegion ir - JOIN ir.region r - WHERE ir.siteUser = :siteUser - )) - AND uifa.term = :term - """) - List findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(@Param("siteUser") SiteUser siteUser, @Param("term") String term); - - @Query(value = """ - SELECT * - FROM university_info_for_apply - WHERE term = :term - ORDER BY RAND() LIMIT :limitNum - """, nativeQuery = true) - List findRandomByTerm(@Param("term") String term, @Param("limitNum") int limitNum); - - default UniversityInfoForApply getUniversityInfoForApplyById(Long id) { - return findById(id) - .orElseThrow(() -> new CustomException(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND)); - } - - default UniversityInfoForApply getUniversityInfoForApplyByIdAndTerm(Long id, String term) { - return findByIdAndTerm(id, term) - .orElseThrow(() -> new CustomException(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM)); - } -} diff --git a/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java b/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java index e4cdeade2..15210c18d 100644 --- a/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/UniversityRepository.java @@ -1,22 +1,12 @@ package com.example.solidconnection.university.repository; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.repository.custom.UniversityFilterRepository; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_NOT_FOUND; - -@Repository -public interface UniversityRepository extends JpaRepository, UniversityFilterRepository { - @Query("SELECT u FROM University u WHERE u.country.code IN :countryCodes OR u.region.code IN :regionCodes") - List findByCountryCodeInOrRegionCodeIn(@Param("countryCodes") List countryCodes, @Param("regionCodes") List regionCodes); +public interface UniversityRepository extends JpaRepository { default University getUniversityById(Long id) { return findById(id) diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepository.java b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepository.java new file mode 100644 index 000000000..c8a6601e3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepository.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.university.repository.custom; + +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import java.util.List; + +public interface UnivApplyInfoFilterRepository { + + List findAllByRegionCodeAndKeywords(String regionCode, List keywords); + + List findAllByFilter(LanguageTestType testType, String testScore, String term, List countryKoreanNames); + + List findAllByText(String text, String term); +} diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java new file mode 100644 index 000000000..3786f697c --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java @@ -0,0 +1,186 @@ +package com.example.solidconnection.university.repository.custom; + +import com.example.solidconnection.location.country.domain.QCountry; +import com.example.solidconnection.location.region.domain.QRegion; +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.QLanguageRequirement; +import com.example.solidconnection.university.domain.QUnivApplyInfo; +import com.example.solidconnection.university.domain.QUniversity; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class UnivApplyInfoFilterRepositoryImpl implements UnivApplyInfoFilterRepository { + + private final JPAQueryFactory queryFactory; + + @Autowired + public UnivApplyInfoFilterRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public List findAllByRegionCodeAndKeywords(String regionCode, List keywords) { + QUnivApplyInfo univApplyInfo = QUnivApplyInfo.univApplyInfo; + QUniversity university = QUniversity.university; + QCountry country = QCountry.country; + QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement; + + return queryFactory + .selectFrom(univApplyInfo) + .join(univApplyInfo.university, university).fetchJoin() + .join(university.country, country).fetchJoin() + .leftJoin(univApplyInfo.languageRequirements, languageRequirement).fetchJoin() + .where( + regionCodeEq(country, regionCode) + .and(countryOrUniversityContainsKeyword(country, university, keywords)) + ) + .distinct() + .fetch(); + } + + private BooleanExpression regionCodeEq(QCountry country, String regionCode) { + if (regionCode == null || regionCode.isEmpty()) { + return Expressions.asBoolean(true).isTrue(); + } + return country.regionCode.eq(regionCode); + } + + private BooleanExpression countryOrUniversityContainsKeyword(QCountry country, QUniversity university, List keywords) { + if (keywords == null || keywords.isEmpty()) { + return Expressions.TRUE; + } + BooleanExpression countryCondition = createKeywordCondition(country.koreanName, keywords); + BooleanExpression universityCondition = createKeywordCondition(university.koreanName, keywords); + return countryCondition.or(universityCondition); + } + + private BooleanExpression createKeywordCondition(StringPath namePath, List keywords) { + return keywords.stream() + .map(namePath::contains) + .reduce(BooleanExpression::or) + .orElse(Expressions.FALSE); + } + + @Override + public List findAllByFilter( + LanguageTestType testType, String testScore, String term, List countryCodes + ) { + QUniversity university = QUniversity.university; + QUnivApplyInfo univApplyInfo = QUnivApplyInfo.univApplyInfo; + QCountry country = QCountry.country; + QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement; + + List filteredUnivApplyInfo = queryFactory.selectFrom(univApplyInfo) + .join(univApplyInfo.university, university) + .join(university.country, country) + .join(univApplyInfo.languageRequirements, languageRequirement) + .fetchJoin() + .where( + languageTestTypeEq(languageRequirement, testType), + termEq(univApplyInfo, term), + countryCodesIn(country, countryCodes) + ) + .distinct() + .fetch(); + + if (testScore == null || testScore.isBlank()) { + return filteredUnivApplyInfo; + } + + /* + * 시험 유형에 따라 성적 비교 방식이 다르다. + * 입력된 점수가 대학에서 요구하는 최소 점수보다 높은지를 '쿼리로' 비교하기엔 쿼리가 지나치게 복잡해진다. + * 따라서 이 부분만 자바 코드로 필터링한다. + * */ + return filteredUnivApplyInfo.stream() + .filter(uai -> isGivenScoreOverMinPassScore(uai, testType, testScore)) + .toList(); + } + + private BooleanExpression languageTestTypeEq( + QLanguageRequirement languageRequirement, LanguageTestType givenTestType + ) { + if (givenTestType == null) { + return null; + } + return languageRequirement.languageTestType.eq(givenTestType); + } + + private BooleanExpression termEq(QUnivApplyInfo univApplyInfo, String givenTerm) { + if (givenTerm == null || givenTerm.isBlank()) { + return null; + } + return univApplyInfo.term.eq(givenTerm); + } + + private BooleanExpression countryCodesIn(QCountry country, List givenCountryCodes) { + if (givenCountryCodes == null || givenCountryCodes.isEmpty()) { + return null; + } + return country.code.in(givenCountryCodes); + } + + private boolean isGivenScoreOverMinPassScore( + UnivApplyInfo univApplyInfo, LanguageTestType givenTestType, String givenTestScore + ) { + return univApplyInfo.getLanguageRequirements().stream() + .filter(languageRequirement -> languageRequirement.getLanguageTestType().equals(givenTestType)) + .findFirst() + .map(requirement -> givenTestType.compare(givenTestScore, requirement.getMinScore())) + .orElse(-1) >= 0; + } + + @Override + public List findAllByText(String text, String term) { + QUnivApplyInfo univApplyInfo = QUnivApplyInfo.univApplyInfo; + QUniversity university = QUniversity.university; + QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement; + QCountry country = QCountry.country; + QRegion region = QRegion.region; + + JPAQuery base = queryFactory.selectFrom(univApplyInfo) + .join(univApplyInfo.university, university).fetchJoin() + .join(university.country, country).fetchJoin() + .join(region).on(country.regionCode.eq(region.code)) + .leftJoin(univApplyInfo.languageRequirements, languageRequirement).fetchJoin() + .where(termEq(univApplyInfo, term)); + + // text 가 비어있다면 모든 대학 지원 정보를 id 오름차순으로 정렬하여 반환 + if (text == null || text.isBlank()) { + return base.orderBy(univApplyInfo.id.asc()).fetch(); + } + + // 매칭 조건 (대학 지원 정보명/국가명/지역명 중 하나라도 포함) + BooleanExpression univApplyInfoLike = univApplyInfo.koreanName.contains(text); + BooleanExpression countryLike = country.koreanName.contains(text); + BooleanExpression regionLike = region.koreanName.contains(text); + BooleanBuilder where = new BooleanBuilder() + .or(univApplyInfoLike) + .or(countryLike) + .or(regionLike); + + // 우선순위 랭크: 대학 지원 정보명(0) > 국가명(1) > 지역명(2) > 그 외(3) + NumberExpression rank = new CaseBuilder() + .when(univApplyInfoLike).then(0) + .when(countryLike).then(1) + .when(regionLike).then(2) + .otherwise(3); + + // 정렬 조건: 랭크 오름차순 > 대학지원정보 id 오름차순 + return base.where(where) + .orderBy(rank.asc(), univApplyInfo.id.asc()) + .fetch(); + } +} diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java deleted file mode 100644 index 009496be7..000000000 --- a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.solidconnection.university.repository.custom; - -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; - -import java.util.List; - -public interface UniversityFilterRepository { - - List findByRegionCodeAndKeywords(String regionCode, List keywords); - - List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( - String regionCode, List keywords, LanguageTestType testType, String testScore, String term); -} diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java deleted file mode 100644 index dd84cfbf5..000000000 --- a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.example.solidconnection.university.repository.custom; - -import com.example.solidconnection.entity.QCountry; -import com.example.solidconnection.entity.QRegion; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.domain.QUniversity; -import com.example.solidconnection.university.domain.QUniversityInfoForApply; -import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.StringPath; -import com.querydsl.jpa.impl.JPAQueryFactory; -import jakarta.persistence.EntityManager; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class UniversityFilterRepositoryImpl implements UniversityFilterRepository { - - private final JPAQueryFactory queryFactory; - - @Autowired - public UniversityFilterRepositoryImpl(EntityManager em) { - this.queryFactory = new JPAQueryFactory(em); - } - - @Override - public List findByRegionCodeAndKeywords(String regionCode, List keywords) { - QUniversity university = QUniversity.university; - QCountry country = QCountry.country; - QRegion region = QRegion.region; - - return queryFactory - .selectFrom(university) - .join(university.country, country) - .join(country.region, region) - .where(regionCodeEq(region, regionCode) - .and(countryOrUniversityContainsKeyword(country, university, keywords)) - ) - .fetch(); - } - - private BooleanExpression regionCodeEq(QRegion region, String regionCode) { - if (regionCode == null || regionCode.isEmpty()) { - return Expressions.asBoolean(true).isTrue(); - } - return region.code.eq(regionCode); - } - - private BooleanExpression countryOrUniversityContainsKeyword(QCountry country, QUniversity university, List keywords) { - if (keywords == null || keywords.isEmpty()) { - return Expressions.TRUE; - } - BooleanExpression countryCondition = createKeywordCondition(country.koreanName, keywords); - BooleanExpression universityCondition = createKeywordCondition(university.koreanName, keywords); - return countryCondition.or(universityCondition); - } - - private BooleanExpression createKeywordCondition(StringPath namePath, List keywords) { - return keywords.stream() - .map(namePath::contains) - .reduce(BooleanExpression::or) - .orElse(Expressions.FALSE); - } - - @Override - public List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( - String regionCode, List keywords, LanguageTestType testType, String testScore, String term) { - - QUniversity university = QUniversity.university; - QCountry country = QCountry.country; - QRegion region = QRegion.region; - QUniversityInfoForApply universityInfoForApply = QUniversityInfoForApply.universityInfoForApply; - - List filteredUniversityInfoForApply = queryFactory - .selectFrom(universityInfoForApply) - .join(universityInfoForApply.university, university) - .join(university.country, country) - .join(university.region, region) - .where(regionCodeEq(region, regionCode) - .and(countryOrUniversityContainsKeyword(country, university, keywords)) - .and(universityInfoForApply.term.eq(term))) - .fetch(); - - if (testScore == null || testScore.isEmpty()) { - if (testType != null) { - return filteredUniversityInfoForApply.stream() - .filter(uifa -> uifa.getLanguageRequirements().stream() - .anyMatch(lr -> lr.getLanguageTestType().equals(testType))) - .toList(); - } - return filteredUniversityInfoForApply; - } - - return filteredUniversityInfoForApply.stream() - .filter(uifa -> compareMyTestScoreToMinPassScore(uifa, testType, testScore) >= 0) - .toList(); - } - - private int compareMyTestScoreToMinPassScore(UniversityInfoForApply universityInfoForApply, LanguageTestType testType, String testScore) { - return universityInfoForApply.getLanguageRequirements().stream() - .filter(languageRequirement -> languageRequirement.getLanguageTestType().equals(testType)) - .findFirst() - .map(requirement -> testType.compare(testScore, requirement.getMinScore())) - .orElse(-1); - } -} diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java similarity index 53% rename from src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java rename to src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java index d39fee1ec..627e579a3 100644 --- a/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java +++ b/src/main/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendService.java @@ -1,35 +1,37 @@ package com.example.solidconnection.university.service; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import static com.example.solidconnection.university.service.UnivApplyInfoRecommendService.RECOMMEND_UNIV_APPLY_INFO_NUM; + +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.util.List; - -import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; - @Service @RequiredArgsConstructor -public class GeneralUniversityRecommendService { +public class GeneralUnivApplyInfoRecommendService { /* * 해당 시기에 열리는 대학교들 중 랜덤으로 선택해서 목록을 구성한다. * */ - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final UnivApplyInfoRepository univApplyInfoRepository; @Getter - private List recommendUniversities; + private List generalRecommends; @Value("${university.term}") public String term; @EventListener(ApplicationReadyEvent.class) public void init() { - recommendUniversities = universityInfoForApplyRepository.findRandomByTerm(term, RECOMMEND_UNIVERSITY_NUM); + Pageable page = PageRequest.of(0, RECOMMEND_UNIV_APPLY_INFO_NUM); + generalRecommends = univApplyInfoRepository.findRandomByTerm(term, page); } } diff --git a/src/main/java/com/example/solidconnection/university/service/LikedUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/university/service/LikedUnivApplyInfoService.java new file mode 100644 index 000000000..0c6e06d21 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/LikedUnivApplyInfoService.java @@ -0,0 +1,84 @@ +package com.example.solidconnection.university.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_LIKED_UNIV_APPLY_INFO; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_LIKED_UNIV_APPLY_INFO; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.university.domain.LikedUnivApplyInfo; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class LikedUnivApplyInfoService { + + private final UnivApplyInfoRepository univApplyInfoRepository; + private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; + + @Value("${university.term}") + public String term; + + /* + * '좋아요'한 대학교 목록을 조회한다. + * */ + @Transactional(readOnly = true) + public List getLikedUnivApplyInfos(long siteUserId) { + List univApplyInfos = likedUnivApplyInfoRepository.findUnivApplyInfosBySiteUserId(siteUserId); + return univApplyInfos.stream() + .map(UnivApplyInfoPreviewResponse::from) + .toList(); + } + + /* + * 대학교를 '좋아요' 한다. + * */ + @Transactional + public void addUnivApplyInfoLike(long siteUserId, Long univApplyInfoId) { + UnivApplyInfo univApplyInfo = univApplyInfoRepository.getUnivApplyInfoById(univApplyInfoId); + + Optional optionalLikedUnivApplyInfo = likedUnivApplyInfoRepository.findBySiteUserIdAndUnivApplyInfoId(siteUserId, univApplyInfo.getId()); + if (optionalLikedUnivApplyInfo.isPresent()) { + throw new CustomException(ALREADY_LIKED_UNIV_APPLY_INFO); + } + + LikedUnivApplyInfo likedUnivApplyInfo = LikedUnivApplyInfo.builder() + .univApplyInfoId(univApplyInfo.getId()) + .siteUserId(siteUserId) + .build(); + likedUnivApplyInfoRepository.save(likedUnivApplyInfo); + } + + /* + * 대학교 '좋아요'를 취소한다. + * */ + @Transactional + public void cancelUnivApplyInfoLike(long siteUserId, long univApplyInfoId) { + UnivApplyInfo univApplyInfo = univApplyInfoRepository.getUnivApplyInfoById(univApplyInfoId); + + Optional optionalLikedUnivApplyInfo = likedUnivApplyInfoRepository.findBySiteUserIdAndUnivApplyInfoId(siteUserId, univApplyInfo.getId()); + if (optionalLikedUnivApplyInfo.isEmpty()) { + throw new CustomException(NOT_LIKED_UNIV_APPLY_INFO); + } + + likedUnivApplyInfoRepository.delete(optionalLikedUnivApplyInfo.get()); + } + + /* + * '좋아요'한 대학교인지 확인한다. + * */ + @Transactional(readOnly = true) + public IsLikeResponse isUnivApplyInfoLiked(long siteUserId, Long univApplyInfoId) { + UnivApplyInfo univApplyInfo = univApplyInfoRepository.getUnivApplyInfoById(univApplyInfoId); + boolean isLike = likedUnivApplyInfoRepository.findBySiteUserIdAndUnivApplyInfoId(siteUserId, univApplyInfo.getId()).isPresent(); + return new IsLikeResponse(isLike); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoQueryService.java b/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoQueryService.java new file mode 100644 index 000000000..bf6ec089a --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoQueryService.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.dto.UnivApplyInfoDetailResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoFilterSearchRequest; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponses; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class UnivApplyInfoQueryService { + + private final UnivApplyInfoRepository univApplyInfoRepository; + + /* + * 대학교 상세 정보를 불러온다. + * - 대학교(University) 정보와 대학 지원 정보(UniversityInfoForApply) 정보를 조합하여 반환한다. + * */ + @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "univApplyInfo:{0}:{1}", cacheManager = "customCacheManager", ttlSec = 86400) + public UnivApplyInfoDetailResponse getUnivApplyInfoDetail(Long univApplyInfoId) { + UnivApplyInfo univApplyInfo + = univApplyInfoRepository.getUnivApplyInfoById(univApplyInfoId); + University university = univApplyInfo.getUniversity(); + + return UnivApplyInfoDetailResponse.of(university, univApplyInfo); + } + + @Transactional(readOnly = true) + public UnivApplyInfoPreviewResponses searchUnivApplyInfoByFilter(UnivApplyInfoFilterSearchRequest request, String term) { + List responses = univApplyInfoRepository + .findAllByFilter(request.languageTestType(), request.testScore(), term, request.countryCode()) + .stream() + .map(UnivApplyInfoPreviewResponse::from) + .toList(); + return new UnivApplyInfoPreviewResponses(responses); + } + + @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "univApplyInfoTextSearch:{0}:{1}", cacheManager = "customCacheManager", ttlSec = 86400) + public UnivApplyInfoPreviewResponses searchUnivApplyInfoByText(String text, String term) { + List responses = univApplyInfoRepository.findAllByText(text, term) + .stream() + .map(UnivApplyInfoPreviewResponse::from) + .toList(); + return new UnivApplyInfoPreviewResponses(responses); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java new file mode 100644 index 000000000..3baeff9f6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendService.java @@ -0,0 +1,72 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoRecommendsResponse; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class UnivApplyInfoRecommendService { + + public static final int RECOMMEND_UNIV_APPLY_INFO_NUM = 6; + + private final UnivApplyInfoRepository univApplyInfoRepository; + private final GeneralUnivApplyInfoRecommendService generalUnivApplyInfoRecommendService; + + @Value("${university.term}") + private String term; + + /* + * 사용자 맞춤 추천 대학교를 불러온다. + * - 회원가입 시 선택한 관심 지역과 관심 국가에 해당하는 대학 중, 이번 term 에 열리는 학교들을 불러온다. + * - 불러온 맞춤 추천 대학교의 순서를 무작위로 섞는다. + * - 맞춤 추천 대학교의 수가 6개보다 적다면, 공통 추천 대학교 후보에서 이번 term 에 열리는 학교들을 부족한 수 만큼 불러온다. + * */ + @Transactional(readOnly = true) + public UnivApplyInfoRecommendsResponse getPersonalRecommends(long siteUserId) { + // 맞춤 추천 대학교를 불러온다. + List personalRecommends = univApplyInfoRepository + .findAllBySiteUsersInterestedCountryOrRegionAndTerm(siteUserId, term); + List trimmedRecommends + = personalRecommends.subList(0, Math.min(RECOMMEND_UNIV_APPLY_INFO_NUM, personalRecommends.size())); + Collections.shuffle(trimmedRecommends); + + // 맞춤 추천 대학교의 수가 6개보다 적다면, 일반 추천 대학교를 부족한 수 만큼 불러온다. + if (trimmedRecommends.size() < RECOMMEND_UNIV_APPLY_INFO_NUM) { + trimmedRecommends.addAll(getGeneralRecommendsExcludingSelected(trimmedRecommends)); + } + + return new UnivApplyInfoRecommendsResponse(trimmedRecommends.stream() + .map(UnivApplyInfoPreviewResponse::from) + .toList()); + } + + private List getGeneralRecommendsExcludingSelected(List alreadyPicked) { + List generalRecommend = new ArrayList<>(generalUnivApplyInfoRecommendService.getGeneralRecommends()); + generalRecommend.removeAll(alreadyPicked); + Collections.shuffle(generalRecommend); + int needed = RECOMMEND_UNIV_APPLY_INFO_NUM - alreadyPicked.size(); + return generalRecommend.subList(0, Math.min(needed, generalRecommend.size())); + } + + /* + * 공통 추천 대학교를 불러온다. + * */ + @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "university:recommend:general", cacheManager = "customCacheManager", ttlSec = 86400) + public UnivApplyInfoRecommendsResponse getGeneralRecommends() { + List generalRecommends = new ArrayList<>(generalUnivApplyInfoRecommendService.getGeneralRecommends()); + return new UnivApplyInfoRecommendsResponse(generalRecommends.stream() + .map(UnivApplyInfoPreviewResponse::from) + .toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java b/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java deleted file mode 100644 index 85971663b..000000000 --- a/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; -import com.example.solidconnection.university.domain.LikedUniversity; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.dto.IsLikeResponse; -import com.example.solidconnection.university.dto.LikeResultResponse; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -import static com.example.solidconnection.custom.exception.ErrorCode.ALREADY_LIKED_UNIVERSITY; -import static com.example.solidconnection.custom.exception.ErrorCode.NOT_LIKED_UNIVERSITY; - -@RequiredArgsConstructor -@Service -public class UniversityLikeService { - - public static final String LIKE_SUCCESS_MESSAGE = "LIKE_SUCCESS"; - public static final String LIKE_CANCELED_MESSAGE = "LIKE_CANCELED"; - - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final LikedUniversityRepository likedUniversityRepository; - - @Value("${university.term}") - public String term; - - /* - * 대학교를 '좋아요' 한다. - * */ - @Transactional - public LikeResultResponse likeUniversity(SiteUser siteUser, Long universityInfoForApplyId) { - UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); - - Optional optionalLikedUniversity = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply); - if (optionalLikedUniversity.isPresent()) { - throw new CustomException(ALREADY_LIKED_UNIVERSITY); - } - - LikedUniversity likedUniversity = LikedUniversity.builder() - .universityInfoForApply(universityInfoForApply) - .siteUser(siteUser) - .build(); - likedUniversityRepository.save(likedUniversity); - return new LikeResultResponse(LIKE_SUCCESS_MESSAGE); - } - - /* - * 대학교 '좋아요'를 취소한다. - * */ - @Transactional - public LikeResultResponse cancelLikeUniversity(SiteUser siteUser, long universityInfoForApplyId) throws CustomException { - UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); - - Optional optionalLikedUniversity = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply); - if (optionalLikedUniversity.isEmpty()) { - throw new CustomException(NOT_LIKED_UNIVERSITY); - } - - likedUniversityRepository.delete(optionalLikedUniversity.get()); - return new LikeResultResponse(LIKE_CANCELED_MESSAGE); - } - - /* - * '좋아요'한 대학교인지 확인한다. - * */ - @Transactional(readOnly = true) - public IsLikeResponse getIsLiked(SiteUser siteUser, Long universityInfoForApplyId) { - UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); - boolean isLike = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply).isPresent(); - return new IsLikeResponse(isLike); - } -} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java b/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java deleted file mode 100644 index f93f3ffae..000000000 --- a/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.dto.UniversityDetailResponse; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponses; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import com.example.solidconnection.university.repository.custom.UniversityFilterRepositoryImpl; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@RequiredArgsConstructor -@Service -public class UniversityQueryService { - - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final UniversityFilterRepositoryImpl universityFilterRepository; - - @Value("${university.term}") - public String term; - - /* - * 대학교 상세 정보를 불러온다. - * - 대학교(University) 정보와 대학 지원 정보(UniversityInfoForApply) 정보를 조합하여 반환한다. - * */ - @Transactional(readOnly = true) - @ThunderingHerdCaching(key = "university:{0}", cacheManager = "customCacheManager", ttlSec = 86400) - public UniversityDetailResponse getUniversityDetail(Long universityInfoForApplyId) { - UniversityInfoForApply universityInfoForApply - = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); - University university = universityInfoForApply.getUniversity(); - - return UniversityDetailResponse.of(university, universityInfoForApply); - } - - /* - * 대학교 검색 결과를 불러온다. - * - 권역, 키워드, 언어 시험 종류, 언어 시험 점수를 조건으로 검색하여 결과를 반환한다. - * - 권역은 영어 대문자로 받는다 e.g. ASIA - * - 키워드는 국가명 또는 대학명에 포함되는 것이 조건이다. - * - 언어 시험 점수는 합격 최소 점수보다 높은 것이 조건이다. - * */ - @Transactional(readOnly = true) - @ThunderingHerdCaching(key = "university:{0}:{1}:{2}:{3}", cacheManager = "customCacheManager", ttlSec = 86400) - public UniversityInfoForApplyPreviewResponses searchUniversity( - String regionCode, List keywords, LanguageTestType testType, String testScore) { - - return new UniversityInfoForApplyPreviewResponses(universityFilterRepository - .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm(regionCode, keywords, testType, testScore, term) - .stream() - .map(UniversityInfoForApplyPreviewResponse::from) - .toList()); - } -} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java deleted file mode 100644 index 4d9ab6242..000000000 --- a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import com.example.solidconnection.university.dto.UniversityRecommendsResponse; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -@RequiredArgsConstructor -@Service -public class UniversityRecommendService { - - public static final int RECOMMEND_UNIVERSITY_NUM = 6; - - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final GeneralUniversityRecommendService generalUniversityRecommendService; - - @Value("${university.term}") - private String term; - - /* - * 사용자 맞춤 추천 대학교를 불러온다. - * - 회원가입 시 선택한 관심 지역과 관심 국가에 해당하는 대학 중, 이번 term 에 열리는 학교들을 불러온다. - * - 불러온 맞춤 추천 대학교의 순서를 무작위로 섞는다. - * - 맞춤 추천 대학교의 수가 6개보다 적다면, 공통 추천 대학교 후보에서 이번 term 에 열리는 학교들을 부족한 수 만큼 불러온다. - * */ - @Transactional(readOnly = true) - public UniversityRecommendsResponse getPersonalRecommends(SiteUser siteUser) { - // 맞춤 추천 대학교를 불러온다. - List personalRecommends = universityInfoForApplyRepository - .findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(siteUser, term); - List trimmedRecommendUniversities - = personalRecommends.subList(0, Math.min(RECOMMEND_UNIVERSITY_NUM, personalRecommends.size())); - Collections.shuffle(trimmedRecommendUniversities); - - // 맞춤 추천 대학교의 수가 6개보다 적다면, 일반 추천 대학교를 부족한 수 만큼 불러온다. - if (trimmedRecommendUniversities.size() < RECOMMEND_UNIVERSITY_NUM) { - trimmedRecommendUniversities.addAll(getGeneralRecommendsExcludingSelected(trimmedRecommendUniversities)); - } - - return new UniversityRecommendsResponse(trimmedRecommendUniversities.stream() - .map(UniversityInfoForApplyPreviewResponse::from) - .toList()); - } - - private List getGeneralRecommendsExcludingSelected(List alreadyPicked) { - List generalRecommend = new ArrayList<>(generalUniversityRecommendService.getRecommendUniversities()); - generalRecommend.removeAll(alreadyPicked); - Collections.shuffle(generalRecommend); - return generalRecommend.subList(0, RECOMMEND_UNIVERSITY_NUM - alreadyPicked.size()); - } - - /* - * 공통 추천 대학교를 불러온다. - * */ - @Transactional(readOnly = true) - @ThunderingHerdCaching(key = "university:recommend:general", cacheManager = "customCacheManager", ttlSec = 86400) - public UniversityRecommendsResponse getGeneralRecommends() { - List generalRecommends = new ArrayList<>(generalUniversityRecommendService.getRecommendUniversities()); - return new UniversityRecommendsResponse(generalRecommends.stream() - .map(UniversityInfoForApplyPreviewResponse::from) - .toList()); - } -} diff --git a/src/main/java/com/example/solidconnection/util/JwtUtils.java b/src/main/java/com/example/solidconnection/util/JwtUtils.java deleted file mode 100644 index d3ea8fed9..000000000 --- a/src/main/java/com/example/solidconnection/util/JwtUtils.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.example.solidconnection.util; - -import com.example.solidconnection.custom.exception.CustomException; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jwts; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.stereotype.Component; - -import java.util.Date; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; - -@Component -public class JwtUtils { - - private static final String TOKEN_HEADER = "Authorization"; - private static final String TOKEN_PREFIX = "Bearer "; - - private JwtUtils() { - } - - public static String parseTokenFromRequest(HttpServletRequest request) { - String token = request.getHeader(TOKEN_HEADER); - if (token == null || token.isBlank() || !token.startsWith(TOKEN_PREFIX)) { - return null; - } - return token.substring(TOKEN_PREFIX.length()); - } - - public static String parseSubjectIgnoringExpiration(String token, String secretKey) { - try { - return parseClaims(token, secretKey).getSubject(); - } catch (ExpiredJwtException e) { - return e.getClaims().getSubject(); - } catch (Exception e) { - throw new CustomException(INVALID_TOKEN); - } - } - - public static String parseSubject(String token, String secretKey) { - try { - return parseClaims(token, secretKey).getSubject(); - } catch (Exception e) { - throw new CustomException(INVALID_TOKEN); - } - } - - public static boolean isExpired(String token, String secretKey) { - try { - Date expiration = Jwts.parser() - .setSigningKey(secretKey) - .parseClaimsJws(token) - .getBody() - .getExpiration(); - return expiration.before(new Date()); - } catch (Exception e) { - return true; - } - } - - public static Claims parseClaims(String token, String secretKey) throws ExpiredJwtException { - return Jwts.parser() - .setSigningKey(secretKey) - .parseClaimsJws(token) - .getBody(); - } -} diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index ed67acac0..df4d7572d 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -1,8 +1,9 @@ package com.example.solidconnection.util; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; +import static com.example.solidconnection.community.post.service.RedisConstants.CREATE_LOCK_PREFIX; +import static com.example.solidconnection.community.post.service.RedisConstants.REFRESH_LOCK_PREFIX; +import static com.example.solidconnection.community.post.service.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX; +import static com.example.solidconnection.community.post.service.RedisConstants.VIEW_COUNT_KEY_PREFIX; import java.util.Collections; import java.util.Comparator; @@ -10,11 +11,9 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; - -import static com.example.solidconnection.type.RedisConstants.CREATE_LOCK_PREFIX; -import static com.example.solidconnection.type.RedisConstants.REFRESH_LOCK_PREFIX; -import static com.example.solidconnection.type.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX; -import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_KEY_PREFIX; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; @Component public class RedisUtils { diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index b1beb524b..433ccb209 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -41,7 +41,7 @@ VALUES ('BN', '브루나이', 'ASIA'), ('MY', '말레이시아', 'ASIA'), ('RU', '러시아', 'EUROPE'); -INSERT INTO site_user (email, nickname, profile_image_url, preparation_stage, role, password, auth_type) +INSERT INTO site_user (email, nickname, profile_image_url, exchange_status, role, password, auth_type) VALUES ('test@test.email', 'yonso', 'https://github.com/nayonsoso.png', 'CONSIDERING', 'MENTEE', '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'); -- 12341234 diff --git a/src/main/resources/db/migration/V12__create_news.sql b/src/main/resources/db/migration/V12__create_news.sql new file mode 100644 index 000000000..1ef91356c --- /dev/null +++ b/src/main/resources/db/migration/V12__create_news.sql @@ -0,0 +1,10 @@ +CREATE TABLE news ( + created_at datetime(6), + id bigint not null auto_increment, + updated_at datetime(6), + thumbnail_url varchar(500), + url varchar(500), + description varchar(255), + title varchar(255), + primary key (id) +) diff --git a/src/main/resources/db/migration/V13__add_application_index_and_delete_manny_to_one_mapping.sql b/src/main/resources/db/migration/V13__add_application_index_and_delete_manny_to_one_mapping.sql new file mode 100644 index 000000000..df8437c3a --- /dev/null +++ b/src/main/resources/db/migration/V13__add_application_index_and_delete_manny_to_one_mapping.sql @@ -0,0 +1,15 @@ +ALTER TABLE application RENAME COLUMN first_choice_university_id TO first_choice_university_info_for_apply_id; +ALTER TABLE application RENAME COLUMN second_choice_university_id TO second_choice_university_info_for_apply_id; +ALTER TABLE application RENAME COLUMN third_choice_university_id TO third_choice_university_info_for_apply_id; + +CREATE INDEX idx_app_user_term_delete + ON application(site_user_id, term, is_delete); + +CREATE INDEX idx_app_first_choice_search + ON application(verify_status, term, is_delete, first_choice_university_info_for_apply_id); + +CREATE INDEX idx_app_second_choice_search + ON application(verify_status, term, is_delete, second_choice_university_info_for_apply_id); + +CREATE INDEX idx_app_third_choice_search + ON application(verify_status, term, is_delete, third_choice_university_info_for_apply_id); diff --git a/src/main/resources/db/migration/V14__set_unique_constraint_to_nickname.sql b/src/main/resources/db/migration/V14__set_unique_constraint_to_nickname.sql new file mode 100644 index 000000000..75d290f7b --- /dev/null +++ b/src/main/resources/db/migration/V14__set_unique_constraint_to_nickname.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_user +ADD CONSTRAINT uk_site_user_nickname +UNIQUE (nickname); diff --git a/src/main/resources/db/migration/V15__add_unique_constraint_to_liked_university.sql b/src/main/resources/db/migration/V15__add_unique_constraint_to_liked_university.sql new file mode 100644 index 000000000..2c49f9ffb --- /dev/null +++ b/src/main/resources/db/migration/V15__add_unique_constraint_to_liked_university.sql @@ -0,0 +1,3 @@ +ALTER TABLE liked_university +ADD CONSTRAINT uk_liked_university_site_user_id_university_info_for_apply_id +UNIQUE (site_user_id, university_info_for_apply_id); diff --git a/src/main/resources/db/migration/V16__add_unique_constraint_to_intersted.sql b/src/main/resources/db/migration/V16__add_unique_constraint_to_intersted.sql new file mode 100644 index 000000000..2f7a8524c --- /dev/null +++ b/src/main/resources/db/migration/V16__add_unique_constraint_to_intersted.sql @@ -0,0 +1,7 @@ +ALTER TABLE interested_country +ADD CONSTRAINT uk_interested_country_site_user_id_country_code +UNIQUE (site_user_id, country_code); + +ALTER TABLE interested_region +ADD CONSTRAINT uk_interested_region_site_user_id_region_code +UNIQUE (site_user_id, region_code); diff --git a/src/main/resources/db/migration/V17__rename_like_university.sql b/src/main/resources/db/migration/V17__rename_like_university.sql new file mode 100644 index 000000000..1d244157a --- /dev/null +++ b/src/main/resources/db/migration/V17__rename_like_university.sql @@ -0,0 +1 @@ +ALTER TABLE liked_university RENAME liked_university_info_for_apply; diff --git a/src/main/resources/db/migration/V18__rename_preparation_status.sql b/src/main/resources/db/migration/V18__rename_preparation_status.sql new file mode 100644 index 000000000..0eba9ae66 --- /dev/null +++ b/src/main/resources/db/migration/V18__rename_preparation_status.sql @@ -0,0 +1 @@ +ALTER TABLE site_user RENAME COLUMN preparation_stage TO exchange_status; diff --git a/src/main/resources/db/migration/V19__create_mentor_related_tables.sql b/src/main/resources/db/migration/V19__create_mentor_related_tables.sql new file mode 100644 index 000000000..5e554d7c1 --- /dev/null +++ b/src/main/resources/db/migration/V19__create_mentor_related_tables.sql @@ -0,0 +1,40 @@ +CREATE TABLE mentor +( + id BIGINT NOT NULL AUTO_INCREMENT, + university_id BIGINT NOT NULL, + site_user_id BIGINT NOT NULL, + mentee_count INT NOT NULL DEFAULT 0, + has_badge BOOLEAN NOT NULL DEFAULT FALSE, + introduction VARCHAR(1000) NULL, + pass_tip VARCHAR(1000) NULL, + PRIMARY KEY (id), + CONSTRAINT fk_mentor_university_id FOREIGN KEY (university_id) REFERENCES university (id), + CONSTRAINT fk_mentor_site_user_id FOREIGN KEY (site_user_id) REFERENCES site_user (id) +); + +CREATE TABLE mentoring +( + id BIGINT NOT NULL AUTO_INCREMENT, + mentor_id BIGINT NOT NULL, + mentee_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + confirmed_at DATETIME(6) NULL, + checked_at DATETIME(6) NULL, + verify_status ENUM ('PENDING', 'REJECTED', 'APPROVED') NOT NULL DEFAULT 'PENDING', + rejected_reason VARCHAR(500) NULL, + PRIMARY KEY (id), + CONSTRAINT fk_mentoring_mentor_id FOREIGN KEY (mentor_id) REFERENCES mentor (id), + CONSTRAINT fk_mentoring_site_user_id FOREIGN KEY (mentee_id) REFERENCES site_user (id) +); + +CREATE TABLE channel +( + id BIGINT NOT NULL AUTO_INCREMENT, + mentor_id BIGINT NOT NULL, + sequence INT NOT NULL, + type ENUM ('BLOG', 'INSTAGRAM', 'YOUTUBE', 'BRUNCH') NOT NULL, + url VARCHAR(500) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_channel_mentor_id FOREIGN KEY (mentor_id) REFERENCES mentor (id), + CONSTRAINT uk_channel_mentor_id_sequence UNIQUE (mentor_id, sequence) +); diff --git a/src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql b/src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql new file mode 100644 index 000000000..84be3d84a --- /dev/null +++ b/src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql @@ -0,0 +1,4 @@ +ALTER TABLE news + ADD COLUMN site_user_id BIGINT NOT NULL; +ALTER TABLE news + ADD CONSTRAINT fk_news_site_user_id FOREIGN KEY (site_user_id) REFERENCES site_user (id); diff --git a/src/main/resources/db/migration/V21__create_liked_news_table.sql b/src/main/resources/db/migration/V21__create_liked_news_table.sql new file mode 100644 index 000000000..e80f65fc1 --- /dev/null +++ b/src/main/resources/db/migration/V21__create_liked_news_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE liked_news ( + id BIGINT NOT NULL AUTO_INCREMENT, + news_id BIGINT NOT NULL, + site_user_id BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_liked_news_site_user_id_news_id UNIQUE (site_user_id, news_id), + CONSTRAINT fk_liked_news_news_id FOREIGN KEY (news_id) REFERENCES news(id), + CONSTRAINT fk_liked_news_site_user_id FOREIGN KEY (site_user_id) REFERENCES site_user(id) +); diff --git a/src/main/resources/db/migration/V22__add_is_deleted_to_comment.sql b/src/main/resources/db/migration/V22__add_is_deleted_to_comment.sql new file mode 100644 index 000000000..a479fbaa2 --- /dev/null +++ b/src/main/resources/db/migration/V22__add_is_deleted_to_comment.sql @@ -0,0 +1,2 @@ +ALTER TABLE comment + ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/resources/db/migration/V23__drop_mentoring_reject_reason_column.sql b/src/main/resources/db/migration/V23__drop_mentoring_reject_reason_column.sql new file mode 100644 index 000000000..dde39c460 --- /dev/null +++ b/src/main/resources/db/migration/V23__drop_mentoring_reject_reason_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE mentoring + DROP COLUMN rejected_reason; diff --git a/src/main/resources/db/migration/V24__add_chat_related_tables.sql b/src/main/resources/db/migration/V24__add_chat_related_tables.sql new file mode 100644 index 000000000..20898a147 --- /dev/null +++ b/src/main/resources/db/migration/V24__add_chat_related_tables.sql @@ -0,0 +1,55 @@ +CREATE TABLE chat_room +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + is_group BOOLEAN NOT NULL DEFAULT false, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL +); + +CREATE TABLE chat_participant +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + site_user_id BIGINT NOT NULL, + chat_room_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT FK_CHAT_PARTICIPANT_CHAT_ROOM_ID FOREIGN KEY (chat_room_id) REFERENCES chat_room (id), + CONSTRAINT FK_CHAT_PARTICIPANT_SITE_USER_ID FOREIGN KEY (site_user_id) REFERENCES site_user (id), + CONSTRAINT UK_CHAT_PARTICIPANT_CHAT_ROOM_ID_SITE_USER_ID UNIQUE (chat_room_id, site_user_id) +); + +CREATE TABLE chat_message +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + content VARCHAR(500) NOT NULL, + sender_id BIGINT NOT NULL, + chat_room_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT FK_CHAT_MESSAGE_CHAT_ROOM_ID FOREIGN KEY (chat_room_id) REFERENCES chat_room (id), + CONSTRAINT FK_CHAT_MESSAGE_SENDER_ID FOREIGN KEY (sender_id) REFERENCES chat_participant (id) +); + +CREATE TABLE chat_attachment +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + is_image BOOLEAN NOT NULL, + url VARCHAR(500) NOT NULL, + thumbnail_url VARCHAR(500), + chat_message_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT FK_CHAT_ATTACHMENT_CHAT_MESSAGE_ID FOREIGN KEY (chat_message_id) REFERENCES chat_message (id) +); + +CREATE TABLE chat_read_status +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + chat_room_id BIGINT NOT NULL, + chat_participant_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT FK_CHAT_READ_STATUS_CHAT_ROOM_ID FOREIGN KEY (chat_room_id) REFERENCES chat_room (id), + CONSTRAINT FK_CHAT_READ_STATUS_CHAT_PARTICIPANT_ID FOREIGN KEY (chat_participant_id) REFERENCES chat_participant (id), + CONSTRAINT UK_CHAT_READ_STATUS_CHAT_ROOM_ID_CHAT_PARTICIPANT_ID UNIQUE (chat_room_id, chat_participant_id) +); diff --git a/src/main/resources/db/migration/V25__create_report_table.sql b/src/main/resources/db/migration/V25__create_report_table.sql new file mode 100644 index 000000000..f40b6f562 --- /dev/null +++ b/src/main/resources/db/migration/V25__create_report_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE report +( + id BIGINT NOT NULL AUTO_INCREMENT, + reporter_id BIGINT NOT NULL, + target_type ENUM ('POST') NOT NULL, + target_id BIGINT NOT NULL, + report_type ENUM ('ADVERTISEMENT', 'SPAM', 'PERSONAL_INFO_EXPOSURE', 'PORNOGRAPHY', 'COPYRIGHT_INFRINGEMENT', 'ILLEGAL_ACTIVITY', 'IMPERSONATION', 'INSULT') NOT NULL, + primary key (id), + constraint fk_report_reporter_id foreign key (reporter_id) references site_user (id), + unique uk_report_reporter_id_target_type_target_id (reporter_id, target_type, target_id) +); diff --git a/src/main/resources/db/migration/V26__add_term_column_to_mentor.sql b/src/main/resources/db/migration/V26__add_term_column_to_mentor.sql new file mode 100644 index 000000000..dbf061090 --- /dev/null +++ b/src/main/resources/db/migration/V26__add_term_column_to_mentor.sql @@ -0,0 +1,2 @@ +ALTER TABLE mentor + ADD COLUMN term varchar(50) NOT NULL; diff --git a/src/main/resources/db/migration/V27__add_checked_at_by_mentee_column.sql b/src/main/resources/db/migration/V27__add_checked_at_by_mentee_column.sql new file mode 100644 index 000000000..41e1ad637 --- /dev/null +++ b/src/main/resources/db/migration/V27__add_checked_at_by_mentee_column.sql @@ -0,0 +1,5 @@ +ALTER TABLE mentoring + RENAME COLUMN checked_at TO checked_at_by_mentor; + +ALTER TABLE mentoring + ADD COLUMN checked_at_by_mentee DATETIME(6) NULL; diff --git a/src/main/resources/db/migration/V28__add_mentoring_id_to_chat_room.sql b/src/main/resources/db/migration/V28__add_mentoring_id_to_chat_room.sql new file mode 100644 index 000000000..f4ab8d815 --- /dev/null +++ b/src/main/resources/db/migration/V28__add_mentoring_id_to_chat_room.sql @@ -0,0 +1,5 @@ +ALTER TABLE chat_room + ADD COLUMN mentoring_id BIGINT, +ADD CONSTRAINT uk_chat_room_mentoring_id UNIQUE (mentoring_id), +ADD CONSTRAINT fk_chat_room_mentoring_id FOREIGN KEY (mentoring_id) REFERENCES mentoring(id); + diff --git a/src/main/resources/db/migration/V29__alter_mentor_introduction_pass_tip_not_null.sql b/src/main/resources/db/migration/V29__alter_mentor_introduction_pass_tip_not_null.sql new file mode 100644 index 000000000..8acb72761 --- /dev/null +++ b/src/main/resources/db/migration/V29__alter_mentor_introduction_pass_tip_not_null.sql @@ -0,0 +1,5 @@ +ALTER TABLE mentor + MODIFY introduction VARCHAR(1000) NOT NULL; + +ALTER TABLE mentor + MODIFY pass_tip VARCHAR(1000) NOT NULL; diff --git a/src/main/resources/db/migration/V30__modify_verify_status_from_varchar_to_enum.sql b/src/main/resources/db/migration/V30__modify_verify_status_from_varchar_to_enum.sql new file mode 100644 index 000000000..c70a2b512 --- /dev/null +++ b/src/main/resources/db/migration/V30__modify_verify_status_from_varchar_to_enum.sql @@ -0,0 +1,8 @@ +ALTER TABLE application +MODIFY COLUMN verify_status ENUM('PENDING', 'REJECTED', 'APPROVED') NOT NULL DEFAULT 'PENDING'; + +ALTER TABLE gpa_score +MODIFY COLUMN verify_status ENUM('PENDING', 'REJECTED', 'APPROVED') NOT NULL DEFAULT 'PENDING'; + +ALTER TABLE language_test_score +MODIFY COLUMN verify_status ENUM('PENDING', 'REJECTED', 'APPROVED') NOT NULL DEFAULT 'PENDING'; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..e179be0fb --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + + /var/log/spring/solid-connection-server.log + + + + /var/log/spring/solid-connection-server.%d{yyyy-MM-dd}.log + 30 + + + + + timestamp=%d{yyyy-MM-dd'T'HH:mm:ss.SSS} level=%-5level thread=%thread logger=%logger{36} + message=%msg%n + + + + + + + + + + + + + + diff --git a/src/main/resources/secret b/src/main/resources/secret index 661dc50f2..fd0d80ad2 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 661dc50f2915b20525a2a00b2b7f6473775f3dc1 +Subproject commit fd0d80ad28d28698e3e27160d9d27bf4e5462238 diff --git a/src/test/java/com/example/solidconnection/custom/validation/validator/RejectedReasonValidatorTest.java b/src/test/java/com/example/solidconnection/admin/dto/validation/RejectedReasonValidatorTest.java similarity index 91% rename from src/test/java/com/example/solidconnection/custom/validation/validator/RejectedReasonValidatorTest.java rename to src/test/java/com/example/solidconnection/admin/dto/validation/RejectedReasonValidatorTest.java index 5af4e8399..ebeb2ccbe 100644 --- a/src/test/java/com/example/solidconnection/custom/validation/validator/RejectedReasonValidatorTest.java +++ b/src/test/java/com/example/solidconnection/admin/dto/validation/RejectedReasonValidatorTest.java @@ -1,23 +1,22 @@ -package com.example.solidconnection.custom.validation.validator; +package com.example.solidconnection.admin.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.REJECTED_REASON_REQUIRED; +import static org.assertj.core.api.Assertions.assertThat; import com.example.solidconnection.admin.dto.GpaScoreUpdateRequest; import com.example.solidconnection.admin.dto.LanguageTestScoreUpdateRequest; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.university.domain.LanguageTestType; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.Set; - -import static com.example.solidconnection.custom.exception.ErrorCode.REJECTED_REASON_REQUIRED; -import static org.assertj.core.api.Assertions.assertThat; - @DisplayName("거절 사유 유효성 검사 테스트") class RejectedReasonValidatorTest { @@ -52,7 +51,7 @@ class GPA_점수_거절_사유_검증 { } @Test - void 거절_상태일_때_거절사유가_없으면_예외_응답을_반환한다() { + void 거절_상태일_때_거절사유가_없으면_예외가_발생한다() { // given GpaScoreUpdateRequest request = new GpaScoreUpdateRequest( 3.0, @@ -92,7 +91,7 @@ class 어학_점수_거절_사유_검증 { } @Test - void 거절_상태일_때_거절사유가_없으면_예외_응답을_반환한다() { + void 거절_상태일_때_거절사유가_없으면_예외가_발생한다() { // given LanguageTestScoreUpdateRequest request = new LanguageTestScoreUpdateRequest( LanguageTestType.TOEIC, diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java index add4115ef..50c12c4b3 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java @@ -1,19 +1,23 @@ package com.example.solidconnection.admin.service; +import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.example.solidconnection.admin.dto.GpaScoreResponse; import com.example.solidconnection.admin.dto.GpaScoreSearchResponse; import com.example.solidconnection.admin.dto.GpaScoreUpdateRequest; import com.example.solidconnection.admin.dto.ScoreSearchCondition; -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.fixture.GpaScoreFixture; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -23,41 +27,31 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import java.time.LocalDate; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.GPA_SCORE_NOT_FOUND; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - +@TestContainerSpringBootTest @DisplayName("학점 검증 관리자 서비스 테스트") -class AdminGpaScoreServiceTest extends BaseIntegrationTest { +class AdminGpaScoreServiceTest { @Autowired private AdminGpaScoreService adminGpaScoreService; @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; @Autowired - private GpaScoreRepository gpaScoreRepository; + private GpaScoreFixture gpaScoreFixture; - private SiteUser siteUser1; - private SiteUser siteUser2; - private SiteUser siteUser3; private GpaScore gpaScore1; private GpaScore gpaScore2; private GpaScore gpaScore3; @BeforeEach void setUp() { - siteUser1 = createSiteUser(1, "test1"); - siteUser2 = createSiteUser(2, "test2"); - siteUser3 = createSiteUser(3, "test3"); - gpaScore3 = createGpaScore(siteUser3, VerifyStatus.REJECTED); - gpaScore2 = createGpaScore(siteUser2, VerifyStatus.PENDING); - gpaScore1 = createGpaScore(siteUser1, VerifyStatus.PENDING); + SiteUser user1 = siteUserFixture.사용자(1, "test1"); + SiteUser user2 = siteUserFixture.사용자(2, "test2"); + SiteUser user3 = siteUserFixture.사용자(3, "test3"); + gpaScore1 = gpaScoreFixture.GPA_점수(VerifyStatus.PENDING, user1); + gpaScore2 = gpaScoreFixture.GPA_점수(VerifyStatus.PENDING, user2); + gpaScore3 = gpaScoreFixture.GPA_점수(VerifyStatus.REJECTED, user3); } @Nested @@ -74,19 +68,10 @@ class 지원한_GPA_목록_조회 { Page response = adminGpaScoreService.searchGpaScores(condition, pageable); // then + assertThat(response.getContent()).hasSize(expectedGpaScores.size()); assertThat(response.getContent()) - .hasSize(expectedGpaScores.size()) - .zipSatisfy(expectedGpaScores, (actual, expected) -> assertAll( - () -> assertThat(actual.gpaScoreStatusResponse().id()).isEqualTo(expected.getId()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpa()).isEqualTo(expected.getGpa().getGpa()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpaCriteria()).isEqualTo(expected.getGpa().getGpaCriteria()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpaReportUrl()).isEqualTo(expected.getGpa().getGpaReportUrl()), - () -> assertThat(actual.gpaScoreStatusResponse().verifyStatus()).isEqualTo(expected.getVerifyStatus()), - - () -> assertThat(actual.siteUserResponse().id()).isEqualTo(expected.getSiteUser().getId()), - () -> assertThat(actual.siteUserResponse().profileImageUrl()).isEqualTo(expected.getSiteUser().getProfileImageUrl()), - () -> assertThat(actual.siteUserResponse().nickname()).isEqualTo(expected.getSiteUser().getNickname()) - )); + .extracting(content -> content.gpaScoreStatusResponse().verifyStatus()) + .containsOnly(VerifyStatus.PENDING); } @Test @@ -100,19 +85,10 @@ class 지원한_GPA_목록_조회 { Page response = adminGpaScoreService.searchGpaScores(condition, pageable); // then + assertThat(response.getContent()).hasSize(expectedGpaScores.size()); assertThat(response.getContent()) - .hasSize(expectedGpaScores.size()) - .zipSatisfy(expectedGpaScores, (actual, expected) -> assertAll( - () -> assertThat(actual.gpaScoreStatusResponse().id()).isEqualTo(expected.getId()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpa()).isEqualTo(expected.getGpa().getGpa()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpaCriteria()).isEqualTo(expected.getGpa().getGpaCriteria()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpaReportUrl()).isEqualTo(expected.getGpa().getGpaReportUrl()), - () -> assertThat(actual.gpaScoreStatusResponse().verifyStatus()).isEqualTo(expected.getVerifyStatus()), - - () -> assertThat(actual.siteUserResponse().id()).isEqualTo(expected.getSiteUser().getId()), - () -> assertThat(actual.siteUserResponse().profileImageUrl()).isEqualTo(expected.getSiteUser().getProfileImageUrl()), - () -> assertThat(actual.siteUserResponse().nickname()).isEqualTo(expected.getSiteUser().getNickname()) - )); + .extracting(content -> content.siteUserResponse().nickname()) + .containsOnly("test1", "test2", "test3"); } @Test @@ -126,19 +102,13 @@ class 지원한_GPA_목록_조회 { Page response = adminGpaScoreService.searchGpaScores(condition, pageable); // then + assertThat(response.getContent()).hasSize(expectedGpaScores.size()); + assertThat(response.getContent()) + .extracting(content -> content.gpaScoreStatusResponse().verifyStatus()) + .containsOnly(VerifyStatus.PENDING); assertThat(response.getContent()) - .hasSize(expectedGpaScores.size()) - .zipSatisfy(expectedGpaScores, (actual, expected) -> assertAll( - () -> assertThat(actual.gpaScoreStatusResponse().id()).isEqualTo(expected.getId()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpa()).isEqualTo(expected.getGpa().getGpa()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpaCriteria()).isEqualTo(expected.getGpa().getGpaCriteria()), - () -> assertThat(actual.gpaScoreStatusResponse().gpaResponse().gpaReportUrl()).isEqualTo(expected.getGpa().getGpaReportUrl()), - () -> assertThat(actual.gpaScoreStatusResponse().verifyStatus()).isEqualTo(expected.getVerifyStatus()), - - () -> assertThat(actual.siteUserResponse().id()).isEqualTo(expected.getSiteUser().getId()), - () -> assertThat(actual.siteUserResponse().profileImageUrl()).isEqualTo(expected.getSiteUser().getProfileImageUrl()), - () -> assertThat(actual.siteUserResponse().nickname()).isEqualTo(expected.getSiteUser().getNickname()) - )); + .extracting(content -> content.siteUserResponse().nickname()) + .containsOnly("test1"); } } @@ -192,7 +162,7 @@ class GPA_점수_검증_및_수정 { } @Test - void 존재하지_않는_GPA_수정_시_예외_응답을_반환한다() { + void 존재하지_않는_GPA_수정_시_예외가_발생한다() { // given long invalidGpaScoreId = 9999L; GpaScoreUpdateRequest request = new GpaScoreUpdateRequest( @@ -208,24 +178,4 @@ class GPA_점수_검증_및_수정 { .hasMessage(GPA_SCORE_NOT_FOUND.getMessage()); } } - - private SiteUser createSiteUser(int index, String nickname) { - SiteUser siteUser = new SiteUser( - "test" + index + " @example.com", - nickname, - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } - - private GpaScore createGpaScore(SiteUser siteUser, VerifyStatus status) { - GpaScore gpaScore = new GpaScore( - new Gpa(4.0, 4.5, "/gpa-report.pdf"), - siteUser - ); - gpaScore.setVerifyStatus(status); - return gpaScoreRepository.save(gpaScore); - } } diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java index a3d00457e..e0afc9610 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java @@ -1,19 +1,24 @@ package com.example.solidconnection.admin.service; +import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; +import static com.example.solidconnection.university.domain.LanguageTestType.TOEIC; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.example.solidconnection.admin.dto.LanguageTestScoreResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreSearchResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreUpdateRequest; import com.example.solidconnection.admin.dto.ScoreSearchCondition; -import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.score.fixture.LanguageTestScoreFixture; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -23,42 +28,31 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import java.time.LocalDate; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; -import static com.example.solidconnection.type.LanguageTestType.TOEIC; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - +@TestContainerSpringBootTest @DisplayName("어학 검증 관리자 서비스 테스트") -class AdminLanguageTestScoreServiceTest extends BaseIntegrationTest { +class AdminLanguageTestScoreServiceTest { @Autowired private AdminLanguageTestScoreService adminLanguageTestScoreService; @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; @Autowired - private LanguageTestScoreRepository languageTestScoreRepository; + private LanguageTestScoreFixture languageTestScoreFixture; - private SiteUser siteUser1; - private SiteUser siteUser2; - private SiteUser siteUser3; private LanguageTestScore languageTestScore1; private LanguageTestScore languageTestScore2; private LanguageTestScore languageTestScore3; @BeforeEach void setUp() { - siteUser1 = createSiteUser(1, "test1"); - siteUser2 = createSiteUser(2, "test2"); - siteUser3 = createSiteUser(3, "test3"); - languageTestScore3 = createLanguageTestScore(siteUser3, VerifyStatus.REJECTED); - languageTestScore2 = createLanguageTestScore(siteUser2, VerifyStatus.PENDING); - languageTestScore1 = createLanguageTestScore(siteUser1, VerifyStatus.PENDING); + SiteUser user1 = siteUserFixture.사용자(1, "test1"); + SiteUser user2 = siteUserFixture.사용자(2, "test2"); + SiteUser user3 = siteUserFixture.사용자(3, "test3"); + languageTestScore1 = languageTestScoreFixture.어학_점수(VerifyStatus.PENDING, user1); + languageTestScore2 = languageTestScoreFixture.어학_점수(VerifyStatus.PENDING, user2); + languageTestScore3 = languageTestScoreFixture.어학_점수(VerifyStatus.REJECTED, user3); } @Nested @@ -75,22 +69,10 @@ class 지원한_어학_목록_조회 { Page response = adminLanguageTestScoreService.searchLanguageTestScores(condition, pageable); // then + assertThat(response.getContent()).hasSize(expectedLanguageTestScores.size()); assertThat(response.getContent()) - .hasSize(expectedLanguageTestScores.size()) - .zipSatisfy(expectedLanguageTestScores, (actual, expected) -> assertAll( - () -> assertThat(actual.languageTestScoreStatusResponse().id()).isEqualTo(expected.getId()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestType()) - .isEqualTo(expected.getLanguageTest().getLanguageTestType()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestScore()) - .isEqualTo(expected.getLanguageTest().getLanguageTestScore()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestReportUrl()) - .isEqualTo(expected.getLanguageTest().getLanguageTestReportUrl()), - () -> assertThat(actual.languageTestScoreStatusResponse().verifyStatus()).isEqualTo(expected.getVerifyStatus()), - - () -> assertThat(actual.siteUserResponse().id()).isEqualTo(expected.getSiteUser().getId()), - () -> assertThat(actual.siteUserResponse().profileImageUrl()).isEqualTo(expected.getSiteUser().getProfileImageUrl()), - () -> assertThat(actual.siteUserResponse().nickname()).isEqualTo(expected.getSiteUser().getNickname()) - )); + .extracting(content -> content.languageTestScoreStatusResponse().verifyStatus()) + .containsOnly(VerifyStatus.PENDING); } @Test @@ -104,22 +86,10 @@ class 지원한_어학_목록_조회 { Page response = adminLanguageTestScoreService.searchLanguageTestScores(condition, pageable); // then + assertThat(response.getContent()).hasSize(expectedLanguageTestScores.size()); assertThat(response.getContent()) - .hasSize(expectedLanguageTestScores.size()) - .zipSatisfy(expectedLanguageTestScores, (actual, expected) -> assertAll( - () -> assertThat(actual.languageTestScoreStatusResponse().id()).isEqualTo(expected.getId()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestType()) - .isEqualTo(expected.getLanguageTest().getLanguageTestType()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestScore()) - .isEqualTo(expected.getLanguageTest().getLanguageTestScore()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestReportUrl()) - .isEqualTo(expected.getLanguageTest().getLanguageTestReportUrl()), - () -> assertThat(actual.languageTestScoreStatusResponse().verifyStatus()).isEqualTo(expected.getVerifyStatus()), - - () -> assertThat(actual.siteUserResponse().id()).isEqualTo(expected.getSiteUser().getId()), - () -> assertThat(actual.siteUserResponse().profileImageUrl()).isEqualTo(expected.getSiteUser().getProfileImageUrl()), - () -> assertThat(actual.siteUserResponse().nickname()).isEqualTo(expected.getSiteUser().getNickname()) - )); + .extracting(content -> content.siteUserResponse().nickname()) + .containsOnly("test1", "test2", "test3"); } @Test @@ -133,22 +103,13 @@ class 지원한_어학_목록_조회 { Page response = adminLanguageTestScoreService.searchLanguageTestScores(condition, pageable); // then + assertThat(response.getContent()).hasSize(expectedLanguageTestScores.size()); + assertThat(response.getContent()) + .extracting(content -> content.languageTestScoreStatusResponse().verifyStatus()) + .containsOnly(VerifyStatus.PENDING); assertThat(response.getContent()) - .hasSize(expectedLanguageTestScores.size()) - .zipSatisfy(expectedLanguageTestScores, (actual, expected) -> assertAll( - () -> assertThat(actual.languageTestScoreStatusResponse().id()).isEqualTo(expected.getId()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestType()) - .isEqualTo(expected.getLanguageTest().getLanguageTestType()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestScore()) - .isEqualTo(expected.getLanguageTest().getLanguageTestScore()), - () -> assertThat(actual.languageTestScoreStatusResponse().languageTestResponse().languageTestReportUrl()) - .isEqualTo(expected.getLanguageTest().getLanguageTestReportUrl()), - () -> assertThat(actual.languageTestScoreStatusResponse().verifyStatus()).isEqualTo(expected.getVerifyStatus()), - - () -> assertThat(actual.siteUserResponse().id()).isEqualTo(expected.getSiteUser().getId()), - () -> assertThat(actual.siteUserResponse().profileImageUrl()).isEqualTo(expected.getSiteUser().getProfileImageUrl()), - () -> assertThat(actual.siteUserResponse().nickname()).isEqualTo(expected.getSiteUser().getNickname()) - )); + .extracting(content -> content.siteUserResponse().nickname()) + .containsOnly("test1"); } } @@ -202,7 +163,7 @@ class 어학점수_검증_및_수정 { } @Test - void 존재하지_않는_어학점수_수정_시_예외_응답을_반환한다() { + void 존재하지_않는_어학점수_수정_시_예외가_발생한다() { // given long invalidLanguageTestScoreId = 9999L; LanguageTestScoreUpdateRequest request = new LanguageTestScoreUpdateRequest( @@ -218,24 +179,4 @@ class 어학점수_검증_및_수정 { .hasMessage(LANGUAGE_TEST_SCORE_NOT_FOUND.getMessage()); } } - - private SiteUser createSiteUser(int index, String nickname) { - SiteUser siteUser = new SiteUser( - "test" + index + " @example.com", - nickname, - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } - - private LanguageTestScore createLanguageTestScore(SiteUser siteUser, VerifyStatus status) { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(TOEIC, "500", "/toeic-report.pdf"), - siteUser - ); - languageTestScore.setVerifyStatus(status); - return languageTestScoreRepository.save(languageTestScore); - } } diff --git a/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java new file mode 100644 index 000000000..9cfc7a805 --- /dev/null +++ b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.application.fixture; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ApplicationFixture { + + private final ApplicationFixtureBuilder applicationFixtureBuilder; + + public Application 지원서( + SiteUser siteUser, + String nicknameForApply, + String term, + Gpa gpa, + LanguageTest languageTest, + Long firstChoiceUnivApplyInfoId, + Long secondChoiceUnivApplyInfoId, + Long thirdChoiceUnivApplyInfoId + ) { + return applicationFixtureBuilder.application() + .siteUser(siteUser) + .gpa(gpa) + .languageTest(languageTest) + .nicknameForApply(nicknameForApply) + .term(term) + .firstChoiceUnivApplyInfoId(firstChoiceUnivApplyInfoId) + .secondChoiceUnivApplyInfoId(secondChoiceUnivApplyInfoId) + .thirdChoiceUnivApplyInfoId(thirdChoiceUnivApplyInfoId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java new file mode 100644 index 000000000..818410938 --- /dev/null +++ b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java @@ -0,0 +1,85 @@ +package com.example.solidconnection.application.fixture; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ApplicationFixtureBuilder { + + private final ApplicationRepository applicationRepository; + + private Gpa gpa; + private LanguageTest languageTest; + private Long firstChoiceUnivApplyInfoId; + private Long secondChoiceUnivApplyInfoId; + private Long thirdChoiceUnivApplyInfoId; + private SiteUser siteUser; + private String nicknameForApply; + private String term; + + public ApplicationFixtureBuilder application() { + return new ApplicationFixtureBuilder(applicationRepository); + } + + public ApplicationFixtureBuilder gpa(Gpa gpa) { + this.gpa = gpa; + return this; + } + + public ApplicationFixtureBuilder languageTest(LanguageTest languageTest) { + this.languageTest = languageTest; + return this; + } + + public ApplicationFixtureBuilder firstChoiceUnivApplyInfoId(Long firstChoiceUnivApplyInfoId) { + this.firstChoiceUnivApplyInfoId = firstChoiceUnivApplyInfoId; + return this; + } + + public ApplicationFixtureBuilder secondChoiceUnivApplyInfoId(Long secondChoiceUnivApplyInfoId) { + this.secondChoiceUnivApplyInfoId = secondChoiceUnivApplyInfoId; + return this; + } + + public ApplicationFixtureBuilder thirdChoiceUnivApplyInfoId(Long thirdChoiceUnivApplyInfoId) { + this.thirdChoiceUnivApplyInfoId = thirdChoiceUnivApplyInfoId; + return this; + } + + public ApplicationFixtureBuilder siteUser(SiteUser siteUser) { + this.siteUser = siteUser; + return this; + } + + public ApplicationFixtureBuilder nicknameForApply(String nicknameForApply) { + this.nicknameForApply = nicknameForApply; + return this; + } + + public ApplicationFixtureBuilder term(String term) { + this.term = term; + return this; + } + + public Application create() { + Application application = new Application( + siteUser, + gpa, + languageTest, + term, + firstChoiceUnivApplyInfoId, + secondChoiceUnivApplyInfoId, + thirdChoiceUnivApplyInfoId, + nicknameForApply + ); + application.setVerifyStatus(VerifyStatus.APPROVED); + return applicationRepository.save(application); + } +} diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java index 583c31d80..a53c6b6bf 100644 --- a/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java @@ -1,32 +1,35 @@ package com.example.solidconnection.application.service; +import static org.assertj.core.api.Assertions.assertThat; + import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.application.domain.LanguageTest; import com.example.solidconnection.application.dto.ApplicantResponse; +import com.example.solidconnection.application.dto.ApplicantsResponse; import com.example.solidconnection.application.dto.ApplicationsResponse; -import com.example.solidconnection.application.dto.UniversityApplicantsResponse; +import com.example.solidconnection.application.fixture.ApplicationFixture; import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.location.region.fixture.RegionFixture; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.score.fixture.GpaScoreFixture; +import com.example.solidconnection.score.fixture.LanguageTestScoreFixture; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.VerifyStatus; -import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - +@TestContainerSpringBootTest @DisplayName("지원서 조회 서비스 테스트") -class ApplicationQueryServiceTest extends BaseIntegrationTest { +class ApplicationQueryServiceTest { @Autowired private ApplicationQueryService applicationQueryService; @@ -35,153 +38,281 @@ class ApplicationQueryServiceTest extends BaseIntegrationTest { private ApplicationRepository applicationRepository; @Autowired - private GpaScoreRepository gpaScoreRepository; + private SiteUserFixture siteUserFixture; + + @Autowired + private RegionFixture regionFixture; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + @Autowired + private GpaScoreFixture gpaScoreFixture; + + @Autowired + private LanguageTestScoreFixture languageTestScoreFixture; @Autowired - private LanguageTestScoreRepository languageTestScoreRepository; + private ApplicationFixture applicationFixture; + + @Value("${university.term}") + private String term; + + private SiteUser user1; + private SiteUser user2; + private SiteUser user3; + + private GpaScore gpaScore1; + private GpaScore gpaScore2; + private GpaScore gpaScore3; + + private LanguageTestScore languageTestScore1; + private LanguageTestScore languageTestScore2; + private LanguageTestScore languageTestScore3; + + private UnivApplyInfo 괌대학_A_지원_정보; + private UnivApplyInfo 괌대학_B_지원_정보; + private UnivApplyInfo 서던덴마크대학교_지원_정보; + + @BeforeEach + void setUp() { + user1 = siteUserFixture.사용자(1, "test1"); + gpaScore1 = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user1); + languageTestScore1 = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user1); + + user2 = siteUserFixture.사용자(2, "test2"); + gpaScore2 = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user2); + languageTestScore2 = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user2); + + user3 = siteUserFixture.사용자(3, "test3"); + gpaScore3 = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user3); + languageTestScore3 = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user3); + + 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보(); + 서던덴마크대학교_지원_정보 = univApplyInfoFixture.서던덴마크대학교_지원_정보(); + } @Nested class 지원자_목록_조회_테스트 { @Test void 이번_학기_전체_지원자를_조회한다() { + // given + Application application1 = applicationFixture.지원서( + user1, + "nickname1", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null + ); + Application application2 = applicationFixture.지원서( + user2, + "nickname2", + term, + gpaScore2.getGpa(), + languageTestScore2.getLanguageTest(), + 괌대학_B_지원_정보.getId(), + null, + null + ); + Application application3 = applicationFixture.지원서( + user3, + "nickname3", + term, + gpaScore3.getGpa(), + languageTestScore3.getLanguageTest(), + 서던덴마크대학교_지원_정보.getId(), + null, + null + ); + // when ApplicationsResponse response = applicationQueryService.getApplicants( - 테스트유저_2, + user1.getId(), "", "" ); // then assertThat(response.firstChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), - UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, - List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))), - UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_7_코펜하겐IT대학_X_X_지원서, false))) - )); - - assertThat(response.secondChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), - UniversityApplicantsResponse.of(그라츠대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), - UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) - )); - - assertThat(response.thirdChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), - UniversityApplicantsResponse.of(서던덴마크대학교_지원_정보, - List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + ApplicantsResponse.of(괌대학_A_지원_정보, + List.of(application1), user1), + ApplicantsResponse.of(괌대학_B_지원_정보, + List.of(application2), user1), + ApplicantsResponse.of(서던덴마크대학교_지원_정보, + List.of(application3), user1) )); } @Test void 이번_학기_특정_지역_지원자를_조회한다() { + //given + Application application1 = applicationFixture.지원서( + user1, + "nickname1", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null + ); + Application application2 = applicationFixture.지원서( + user2, + "nickname2", + term, + gpaScore2.getGpa(), + languageTestScore2.getLanguageTest(), + 괌대학_B_지원_정보.getId(), + null, + null + ); + applicationFixture.지원서( + user3, + "nickname3", + term, + gpaScore3.getGpa(), + languageTestScore3.getLanguageTest(), + 서던덴마크대학교_지원_정보.getId(), + null, + null + ); + // when ApplicationsResponse response = applicationQueryService.getApplicants( - 테스트유저_2, - 영미권.getCode(), + user1.getId(), + regionFixture.영미권().getCode(), "" ); // then - assertThat(response.firstChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), - UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, - List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) - )); - - assertThat(response.secondChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) - )); + assertThat(response.firstChoice()).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, + List.of(application1), user1), + ApplicantsResponse.of(괌대학_B_지원_정보, + List.of(application2), user1) + ); } @Test void 이번_학기_지원자를_대학_국문_이름으로_필터링해서_조회한다() { + //given + Application application1 = applicationFixture.지원서( + user1, + "nickname1", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null + ); + Application application2 = applicationFixture.지원서( + user2, + "nickname2", + term, + gpaScore2.getGpa(), + languageTestScore2.getLanguageTest(), + 괌대학_B_지원_정보.getId(), + null, + null + ); + applicationFixture.지원서( + user3, + "nickname3", + term, + gpaScore3.getGpa(), + languageTestScore3.getLanguageTest(), + 서던덴마크대학교_지원_정보.getId(), + null, + null + ); + // when ApplicationsResponse response = applicationQueryService.getApplicants( - 테스트유저_2, + user1.getId(), null, - "일본" + "괌" ); // then - assertThat(response.firstChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))) - )); - - assertThat(response.secondChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(메이지대학_지원_정보, List.of()) - )); - - assertThat(response.thirdChoice()).containsExactlyInAnyOrder( - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + assertThat(response.firstChoice()).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, + List.of(application1), user1), + ApplicantsResponse.of(괌대학_B_지원_정보, + List.of(application2), user1) ); } @Test void 이전_학기_지원자는_조회되지_않는다() { + // given + Application application = applicationFixture.지원서( + user1, + "nickname1", + "1988-1", + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null + ); + // when ApplicationsResponse response = applicationQueryService.getApplicants( - 테스트유저_1, + user1.getId(), "", "" ); // then assertThat(response.firstChoice()).doesNotContainAnyElementsOf(List.of( - UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, - List.of(ApplicantResponse.of(이전학기_지원서, false))) - )); - assertThat(response.secondChoice()).doesNotContainAnyElementsOf(List.of( - UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, - List.of(ApplicantResponse.of(이전학기_지원서, false))) - )); - assertThat(response.thirdChoice()).doesNotContainAnyElementsOf(List.of( - UniversityApplicantsResponse.of(메이지대학_지원_정보, - List.of(ApplicantResponse.of(이전학기_지원서, false))) + ApplicantsResponse.of(괌대학_A_지원_정보, + List.of(application), user1) )); } @Test void 동일_유저의_여러_지원서_중_최신_지원서만_조회된다() { // given - Application firstApplication = createApplication(테스트유저_1, 괌대학_A_지원_정보); + Application firstApplication = applicationFixture.지원서( + user1, + "nickname1", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null + ); firstApplication.setIsDeleteTrue(); applicationRepository.save(firstApplication); - Application secondApplication = createApplication(테스트유저_1, 네바다주립대학_라스베이거스_지원_정보); - + Application secondApplication = applicationFixture.지원서( + user1, + "nickname2", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_B_지원_정보.getId(), + null, + null + ); // when ApplicationsResponse response = applicationQueryService.getApplicants( - 테스트유저_1, "", ""); + user1.getId(), + "", + "" + ); // then assertThat(response.firstChoice().stream() - .flatMap(univ -> univ.applicants().stream()) - .filter(ApplicantResponse::isMine)) + .flatMap(univ -> univ.applicants().stream()) + .filter(ApplicantResponse::isMine)) .containsExactly(ApplicantResponse.of(secondApplication, true)); } } @@ -191,100 +322,93 @@ class 경쟁자_목록_조회_테스트 { @Test void 이번_학기_지원한_대학의_경쟁자_목록을_조회한다() { - // when - ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( - 테스트유저_2 + // given + Application application1 = applicationFixture.지원서( + user1, + "nickname1", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null + ); + Application application2 = applicationFixture.지원서( + user2, + "nickname2", + term, + gpaScore2.getGpa(), + languageTestScore2.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null ); + applicationFixture.지원서( + user3, + "nickname3", + term, + gpaScore3.getGpa(), + languageTestScore3.getLanguageTest(), + 서던덴마크대학교_지원_정보.getId(), + null, + null + ); + // when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications(user1.getId()); // then - assertThat(response.firstChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) - )); - - assertThat(response.secondChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(괌대학_A_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), - UniversityApplicantsResponse.of(괌대학_B_지원_정보, - List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) - )); - - assertThat(response.thirdChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))) - )); + assertThat(response.firstChoice()).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, + List.of(application1, application2), user1) + ); } @Test void 이번_학기_지원한_대학_중_미선택이_있을_때_경쟁자_목록을_조회한다() { - // when - ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( - 테스트유저_7 + // given + Application application1 = applicationFixture.지원서( + user1, + "nickname1", + term, + gpaScore1.getGpa(), + languageTestScore1.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + null, + null ); - - // then - assertThat(response.firstChoice()).containsAll(List.of( - UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, - List.of(ApplicantResponse.of(테스트유저_7_코펜하겐IT대학_X_X_지원서, true))) - )); - - assertThat(response.secondChoice()).containsExactlyInAnyOrder( - UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, List.of()) + Application application2 = applicationFixture.지원서( + user2, + "nickname2", + term, + gpaScore2.getGpa(), + languageTestScore2.getLanguageTest(), + 괌대학_A_지원_정보.getId(), + 괌대학_B_지원_정보.getId(), + 서던덴마크대학교_지원_정보.getId() ); - - assertThat(response.thirdChoice()).containsExactlyInAnyOrder( - UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, List.of()) + Application application3 = applicationFixture.지원서( + user3, + "nickname3", + term, + gpaScore3.getGpa(), + languageTestScore3.getLanguageTest(), + 서던덴마크대학교_지원_정보.getId(), + null, + null ); - } - @Test - void 이번_학기_지원한_대학이_모두_미선택일_때_경쟁자_목록을_조회한다() { - //when - ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( - 테스트유저_6 - ); + // when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications(user1.getId()); // then - assertThat(response.firstChoice()).isEmpty(); - assertThat(response.secondChoice()).isEmpty(); - assertThat(response.thirdChoice()).isEmpty(); + assertThat(response.firstChoice()) + .hasSize(1) + .allSatisfy(uar -> { + assertThat(uar.koreanName()).isEqualTo(괌대학_A_지원_정보.getKoreanName()); + assertThat(uar.applicants()) + .extracting(ApplicantResponse::nicknameForApply) + .containsExactlyInAnyOrder("nickname1", "nickname2"); + }); } } - - private GpaScore createApprovedGpaScore(SiteUser siteUser) { - GpaScore gpaScore = new GpaScore( - new Gpa(4.0, 4.5, "/gpa-report.pdf"), - siteUser - ); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - return gpaScoreRepository.save(gpaScore); - } - - private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), - siteUser - ); - languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); - return languageTestScoreRepository.save(languageTestScore); - } - - private Application createApplication( - SiteUser siteUser, - UniversityInfoForApply universityInfoForApply) { - Application application = new Application( - siteUser, - createApprovedGpaScore(siteUser).getGpa(), - createApprovedLanguageTestScore(siteUser).getLanguageTest(), - term, - universityInfoForApply, - null, - null, - null - ); - application.setVerifyStatus(VerifyStatus.APPROVED); - return applicationRepository.save(application); - } } diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java index f4f442840..5281faa3e 100644 --- a/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java @@ -1,35 +1,38 @@ package com.example.solidconnection.application.service; +import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; +import static com.example.solidconnection.common.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.application.domain.LanguageTest; import com.example.solidconnection.application.dto.ApplicationSubmissionResponse; import com.example.solidconnection.application.dto.ApplyRequest; -import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; import com.example.solidconnection.application.repository.ApplicationRepository; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.score.fixture.GpaScoreFixture; +import com.example.solidconnection.score.fixture.LanguageTestScoreFixture; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; -import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; -import static com.example.solidconnection.custom.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - +@TestContainerSpringBootTest @DisplayName("지원서 제출 서비스 테스트") -class ApplicationSubmissionServiceTest extends BaseIntegrationTest { +class ApplicationSubmissionServiceTest { @Autowired private ApplicationSubmissionService applicationSubmissionService; @@ -38,138 +41,127 @@ class ApplicationSubmissionServiceTest extends BaseIntegrationTest { private ApplicationRepository applicationRepository; @Autowired - private GpaScoreRepository gpaScoreRepository; + private SiteUserFixture siteUserFixture; @Autowired - private LanguageTestScoreRepository languageTestScoreRepository; + private UnivApplyInfoFixture univApplyInfoFixture; + + @Autowired + private GpaScoreFixture gpaScoreFixture; + + @Autowired + private LanguageTestScoreFixture languageTestScoreFixture; + + @Value("${university.term}") + private String term; + + private SiteUser user; + private UnivApplyInfo 괌대학_A_지원_정보; + private UnivApplyInfo 괌대학_B_지원_정보; + private UnivApplyInfo 서던덴마크대학교_지원_정보; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보(); + 서던덴마크대학교_지원_정보 = univApplyInfoFixture.서던덴마크대학교_지원_정보(); + } @Test void 정상적으로_지원서를_제출한다() { // given - GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); - LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); - UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user); + LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user); + UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( 괌대학_A_지원_정보.getId(), - 네바다주립대학_라스베이거스_지원_정보.getId(), - 메모리얼대학_세인트존스_A_지원_정보.getId() + 괌대학_B_지원_정보.getId(), + 서던덴마크대학교_지원_정보.getId() ); - ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); // when - ApplicationSubmissionResponse response = applicationSubmissionService.apply(테스트유저_1, request); + ApplicationSubmissionResponse response = applicationSubmissionService.apply(user.getId(), request); // then - Application savedApplication = applicationRepository.findBySiteUserAndTerm(테스트유저_1, term).orElseThrow(); + Application savedApplication = applicationRepository.findBySiteUserIdAndTerm(user.getId(), term).orElseThrow(); assertAll( - () -> assertThat(response.applyCount()).isEqualTo(savedApplication.getUpdateCount()), - () -> assertThat(savedApplication.getGpa()).isEqualTo(gpaScore.getGpa()), - () -> assertThat(savedApplication.getLanguageTest()).isEqualTo(languageTestScore.getLanguageTest()), - () -> assertThat(savedApplication.getVerifyStatus()).isEqualTo(VerifyStatus.APPROVED), - () -> assertThat(savedApplication.getNicknameForApply()).isNotNull(), - () -> assertThat(savedApplication.getTerm()).isEqualTo(term), - () -> assertThat(savedApplication.isDelete()).isFalse(), - () -> assertThat(savedApplication.getFirstChoiceUniversity().getId()).isEqualTo(괌대학_A_지원_정보.getId()), - () -> assertThat(savedApplication.getSecondChoiceUniversity().getId()).isEqualTo(네바다주립대학_라스베이거스_지원_정보.getId()), - () -> assertThat(savedApplication.getThirdChoiceUniversity().getId()).isEqualTo(메모리얼대학_세인트존스_A_지원_정보.getId()), - () -> assertThat(savedApplication.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + () -> assertThat(response.applyCount()) + .isEqualTo(savedApplication.getUpdateCount()), + () -> assertThat(savedApplication.getVerifyStatus()) + .isEqualTo(VerifyStatus.APPROVED), + () -> assertThat(savedApplication.isDelete()) + .isFalse(), + () -> assertThat(savedApplication.getFirstChoiceUnivApplyInfoId()) + .isEqualTo(괌대학_A_지원_정보.getId()), + () -> assertThat(savedApplication.getSecondChoiceUnivApplyInfoId()) + .isEqualTo(괌대학_B_지원_정보.getId()), + () -> assertThat(savedApplication.getThirdChoiceUnivApplyInfoId()) + .isEqualTo(서던덴마크대학교_지원_정보.getId()) ); } @Test - void 미승인된_GPA_성적으로_지원하면_예외_응답을_반환한다() { + void 미승인된_GPA_성적으로_지원하면_예외가_발생한다() { // given - GpaScore gpaScore = createUnapprovedGpaScore(테스트유저_1); - LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); - UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.PENDING, user); + LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user); + UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( 괌대학_A_지원_정보.getId(), null, null ); - ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); // when & then assertThatCode(() -> - applicationSubmissionService.apply(테스트유저_1, request) + applicationSubmissionService.apply(user.getId(), request) ) .isInstanceOf(CustomException.class) .hasMessage(INVALID_GPA_SCORE_STATUS.getMessage()); } @Test - void 미승인된_어학성적으로_지원하면_예외_응답을_반환한다() { + void 미승인된_어학성적으로_지원하면_예외가_발생한다() { // given - GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); - LanguageTestScore languageTestScore = createUnapprovedLanguageTestScore(테스트유저_1); - UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user); + LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.PENDING, user); + UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( 괌대학_A_지원_정보.getId(), null, null ); - ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); // when & then assertThatCode(() -> - applicationSubmissionService.apply(테스트유저_1, request) + applicationSubmissionService.apply(user.getId(), request) ) .isInstanceOf(CustomException.class) .hasMessage(INVALID_LANGUAGE_TEST_SCORE_STATUS.getMessage()); } @Test - void 지원서_수정_횟수를_초과하면_예외_응답을_반환한다() { + void 지원서_수정_횟수를_초과하면_예외가_발생한다() { // given - GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); - LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); - UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user); + LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user); + UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( 괌대학_A_지원_정보.getId(), null, null ); - ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); for (int i = 0; i < APPLICATION_UPDATE_COUNT_LIMIT; i++) { - applicationSubmissionService.apply(테스트유저_1, request); + applicationSubmissionService.apply(user.getId(), request); } // when & then assertThatCode(() -> - applicationSubmissionService.apply(테스트유저_1, request) + applicationSubmissionService.apply(user.getId(), request) ) .isInstanceOf(CustomException.class) .hasMessage(APPLY_UPDATE_LIMIT_EXCEED.getMessage()); } - - private GpaScore createUnapprovedGpaScore(SiteUser siteUser) { - GpaScore gpaScore = new GpaScore( - new Gpa(4.0, 4.5, "/gpa-report.pdf"), - siteUser - ); - return gpaScoreRepository.save(gpaScore); - } - - private GpaScore createApprovedGpaScore(SiteUser siteUser) { - GpaScore gpaScore = new GpaScore( - new Gpa(4.0, 4.5, "/gpa-report.pdf"), - siteUser - ); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - return gpaScoreRepository.save(gpaScore); - } - - private LanguageTestScore createUnapprovedLanguageTestScore(SiteUser siteUser) { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), - siteUser - ); - return languageTestScoreRepository.save(languageTestScore); - } - - private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), - siteUser - ); - languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); - return languageTestScoreRepository.save(languageTestScore); - } } diff --git a/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java b/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java new file mode 100644 index 000000000..a5924b860 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java @@ -0,0 +1,143 @@ +package com.example.solidconnection.auth.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +import com.example.solidconnection.auth.controller.config.RefreshTokenCookieProperties; +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.web.server.Cookie.SameSite; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@DisplayName("리프레시 토큰 쿠키 매니저 테스트") +@TestContainerSpringBootTest +class RefreshTokenCookieManagerTest { + + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + + @Autowired + private RefreshTokenCookieManager cookieManager; + + @MockBean + private RefreshTokenCookieProperties refreshTokenCookieProperties; + + private final String domain = "example.com"; + + @BeforeEach + void setUp() { + given(refreshTokenCookieProperties.cookieDomain()).willReturn(domain); + } + + @Test + void 리프레시_토큰을_쿠키로_설정한다() { + // given + MockHttpServletResponse response = new MockHttpServletResponse(); + String refreshToken = "test-refresh-token"; + + // when + cookieManager.setCookie(response, refreshToken); + + // then + String header = response.getHeader("Set-Cookie"); + assertAll( + () -> assertThat(header).isNotNull(), + () -> assertThat(header).contains(REFRESH_TOKEN_COOKIE_NAME + "=" + refreshToken), + () -> assertThat(header).contains("HttpOnly"), + () -> assertThat(header).contains("Secure"), + () -> assertThat(header).contains("Path=/"), + () -> assertThat(header).contains("Max-Age=" + TokenType.REFRESH.getExpireTime() / 1000), + () -> assertThat(header).contains("Domain=" + domain), + () -> assertThat(header).contains("SameSite=" + SameSite.LAX.attributeValue()) + ); + } + + @Test + void 쿠키에서_리프레시_토큰을_삭제한다() { + // given + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + cookieManager.deleteCookie(response); + + // then + String header = response.getHeader("Set-Cookie"); + assertAll( + () -> assertThat(header).isNotNull(), + () -> assertThat(header).contains(REFRESH_TOKEN_COOKIE_NAME + "="), + () -> assertThat(header).contains("HttpOnly"), + () -> assertThat(header).contains("Secure"), + () -> assertThat(header).contains("Path=/"), + () -> assertThat(header).contains("Max-Age=0"), + () -> assertThat(header).contains("Domain=" + domain), + () -> assertThat(header).contains("SameSite=" + SameSite.LAX.attributeValue()) + ); + } + + @Nested + class 쿠키에서_리프레시_토큰을_추출한다 { + + @Test + void 리프레시_토큰이_있으면_정상_반환한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + String refreshToken = "test-refresh-token"; + request.setCookies(new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken)); + + // when + String retrievedToken = cookieManager.getRefreshToken(request); + + // then + assertThat(retrievedToken).isEqualTo(refreshToken); + } + + @Test + void 쿠키가_없으면_예외가_발생한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + + // when & then + assertThatCode(() -> cookieManager.getRefreshToken(request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_EXISTS.getMessage()); + } + + @Test + void 리프레시_토큰_쿠키가_없으면_예외가_발생한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("otherCookie", "some-value")); + + // when & then + assertThatCode(() -> cookieManager.getRefreshToken(request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_EXISTS.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void 리프레시_토큰_쿠키가_비어있으면_예외가_발생한다(String token) { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(REFRESH_TOKEN_COOKIE_NAME, token)); + + // when & then + assertThatCode(() -> cookieManager.getRefreshToken(request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_EXISTS.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/auth/dto/validation/PasswordValidatorTest.java b/src/test/java/com/example/solidconnection/auth/dto/validation/PasswordValidatorTest.java new file mode 100644 index 000000000..8beb4c99e --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/dto/validation/PasswordValidatorTest.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.auth.dto.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("비밀번호 유효성 검사 테스트") +class PasswordValidatorTest { + + private final PasswordValidator validator = new PasswordValidator(); + + @Test + void 정상_패턴이면_true를_반환한다() { + assertThat(validator.isValid("abcd123!", null)).isTrue(); + } + + @Test + void 숫자가_없으면_false를_반환한다() { + assertThat(validator.isValid("abcdefg!", null)).isFalse(); + } + + @Test + void 영문자가_없으면_false를_반환한다() { + assertThat(validator.isValid("1234567!", null)).isFalse(); + } + + @Test + void 특수문자가_없으면_false를_반환한다() { + assertThat(validator.isValid("abcd1234", null)).isFalse(); + } + + @Test + void 공백을_포함하면_false를_반환한다() { + assertThat(validator.isValid("abcd123! ", null)).isFalse(); + } + + @Test + void 길이가_8자_미만이면_false를_반환한다() { + assertThat(validator.isValid("ab1!ab", null)).isFalse(); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java new file mode 100644 index 000000000..caedec489 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java @@ -0,0 +1,112 @@ +package com.example.solidconnection.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.dto.ReissueResponse; +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +@DisplayName("인증 서비스 테스트") +@TestContainerSpringBootTest +class AuthServiceTest { + + @Autowired + private AuthService authService; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private TokenBlackListService tokenBlackListService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + private AccessToken accessToken; + + @BeforeEach + void setUp() { + siteUser = siteUserFixture.사용자(); + accessToken = authTokenProvider.generateAccessToken(siteUser); + } + + @Test + void 로그아웃한다() { + // when + authService.signOut(accessToken.token()); + + // then + String refreshTokenKey = TokenType.REFRESH.addPrefix(accessToken.subject().value()); + assertAll( + () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(), + () -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue() + ); + } + + @Test + void 탈퇴한다() { + // when + authService.quit(siteUser.getId(), accessToken.token()); + + // then + LocalDate tomorrow = LocalDate.now().plusDays(1); + String refreshTokenKey = TokenType.REFRESH.addPrefix(accessToken.subject().value()); + SiteUser actualSitUser = siteUserRepository.findById(siteUser.getId()).orElseThrow(); + assertAll( + () -> assertThat(actualSitUser.getQuitedAt()).isEqualTo(tomorrow), + () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(), + () -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue() + ); + } + + @Nested + class 토큰을_재발급한다 { + + @Test + void 요청의_리프레시_토큰이_저장되어_있으면_액세스_토큰을_재발급한다() { + // given + RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + + // when + ReissueResponse reissuedAccessToken = authService.reissue(refreshToken.token()); + + // then - 요청의 리프레시 토큰과 재발급한 액세스 토큰의 주체가 동일해야 한다. + SiteUser actualSiteUser = authTokenProvider.parseSiteUser(refreshToken.token()); + SiteUser expectedSiteUser = authTokenProvider.parseSiteUser(reissuedAccessToken.accessToken()); + assertThat(actualSiteUser.getId()).isEqualTo(expectedSiteUser.getId()); + } + + @Test + void 요청의_리프레시_토큰이_저장되어있지_않다면_예외가_발생한다() { + // given + String invalidRefreshToken = accessToken.token(); + + // when, then + assertThatCode(() -> authService.reissue(invalidRefreshToken)) + .isInstanceOf(CustomException.class) + .hasMessage(REFRESH_TOKEN_EXPIRED.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java index 57a9ea789..54dce4f68 100644 --- a/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java @@ -1,14 +1,12 @@ package com.example.solidconnection.auth.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.config.security.JwtProperties; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.util.JwtUtils; -import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -16,11 +14,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - @TestContainerSpringBootTest @DisplayName("인증 토큰 제공자 테스트") class AuthTokenProviderTest { @@ -28,157 +21,88 @@ class AuthTokenProviderTest { @Autowired private AuthTokenProvider authTokenProvider; - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired private RedisTemplate redisTemplate; @Autowired - private JwtProperties jwtProperties; + private SiteUserFixture siteUserFixture; private SiteUser siteUser; - private String subject; + private String expectedSubject; @BeforeEach void setUp() { - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - subject = siteUser.getId().toString(); + siteUser = siteUserFixture.사용자(); + expectedSubject = siteUser.getId().toString(); } - @Nested - class 액세스_토큰을_제공한다 { - - @Test - void SiteUser_로_액세스_토큰을_생성한다() { - // when - String token = authTokenProvider.generateAccessToken(siteUser); - - // then - String actualSubject = JwtUtils.parseSubject(token, jwtProperties.secret()); - assertThat(actualSubject).isEqualTo(subject); - } - - @Test - void subject_로_액세스_토큰을_생성한다() { - // given - String subject = "subject123"; - - // when - String token = authTokenProvider.generateAccessToken(subject); + @Test + void 액세스_토큰을_생성한다() { + // when + AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); - // then - String actualSubject = JwtUtils.parseSubject(token, jwtProperties.secret()); - assertThat(actualSubject).isEqualTo(subject); - } + // then + assertAll( + () -> assertThat(accessToken.subject().value()).isEqualTo(expectedSubject), + () -> assertThat(accessToken.role()).isEqualTo(siteUser.getRole()), + () -> assertThat(accessToken.token()).isNotNull() + ); } @Nested class 리프레시_토큰을_제공한다 { @Test - void SiteUser_로_리프레시_토큰을_생성하고_저장한다() { + void 리프레시_토큰을_생성하고_저장한다() { // when - String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + RefreshToken actualRefreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); // then - String actualSubject = JwtUtils.parseSubject(refreshToken, jwtProperties.secret()); - String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); + String refreshTokenKey = TokenType.REFRESH.addPrefix(expectedSubject); + String expectedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); assertAll( - () -> assertThat(actualSubject).isEqualTo(subject), - () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isEqualTo(refreshToken) + () -> assertThat(actualRefreshToken.subject().value()).isEqualTo(expectedSubject), + () -> assertThat(actualRefreshToken.token()).isEqualTo(expectedRefreshToken) ); } @Test - void 저장된_리프레시_토큰을_조회한다() { + void 유효한_리프레시_토큰인지_확인한다() { // given - String refreshToken = "refreshToken"; - redisTemplate.opsForValue().set(TokenType.REFRESH.addPrefix(subject), refreshToken); + RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + AccessToken fakeRefreshToken = authTokenProvider.generateAccessToken(siteUser); - // when - Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); - - // then - assertThat(optionalRefreshToken.get()).isEqualTo(refreshToken); - } - - @Test - void 저장되지_않은_리프레시_토큰을_조회한다() { - // when - Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); - - // then - assertThat(optionalRefreshToken).isEmpty(); - } - } - - @Nested - class 블랙리스트_토큰을_제공한다 { - - @Test - void 엑세스_토큰으로_블랙리스트_토큰을_생성하고_저장한다() { - // when - String accessToken = "accessToken"; - String blackListToken = authTokenProvider.generateAndSaveBlackListToken(accessToken); - - // then - String actualSubject = JwtUtils.parseSubject(blackListToken, jwtProperties.secret()); - String blackListTokenKey = TokenType.BLACKLIST.addPrefix(accessToken); + // when, then assertAll( - () -> assertThat(actualSubject).isEqualTo(accessToken), - () -> assertThat(redisTemplate.opsForValue().get(blackListTokenKey)).isEqualTo(blackListToken) + () -> assertThat(authTokenProvider.isValidRefreshToken(refreshToken.token())).isTrue(), + () -> assertThat(authTokenProvider.isValidRefreshToken(fakeRefreshToken.token())).isFalse() ); } @Test - void 저장된_블랙리스트_토큰을_조회한다() { + void 액세스_토큰에_해당하는_리프레시_토큰을_삭제한다() { // given - String accessToken = "accessToken"; - String blackListToken = "token"; - redisTemplate.opsForValue().set(TokenType.BLACKLIST.addPrefix(accessToken), blackListToken); + authTokenProvider.generateAndSaveRefreshToken(siteUser); + AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); // when - Optional optionalBlackListToken = authTokenProvider.findBlackListToken(accessToken); + authTokenProvider.deleteRefreshTokenByAccessToken(accessToken); // then - assertThat(optionalBlackListToken).hasValue(blackListToken); - } - - @Test - void 저장되지_않은_블랙리스트_토큰을_조회한다() { - // when - Optional optionalBlackListToken = authTokenProvider.findBlackListToken("accessToken"); - - // then - assertThat(optionalBlackListToken).isEmpty(); + String refreshTokenKey = TokenType.REFRESH.addPrefix(expectedSubject); + assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(); } } @Test - void 토큰을_생성한다() { + void 토큰으로부터_SiteUser_를_추출한다() { + // given + String accessToken = authTokenProvider.generateAccessToken(siteUser).token(); + // when - String subject = "subject123"; - String token = authTokenProvider.generateToken(subject, TokenType.ACCESS); + SiteUser actualSitUser = authTokenProvider.parseSiteUser(accessToken); // then - String extractedSubject = Jwts.parser() - .setSigningKey(jwtProperties.secret()) - .parseClaimsJws(token) - .getBody() - .getSubject(); - assertThat(subject).isEqualTo(extractedSubject); - } - - private SiteUser createSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); + assertThat(actualSitUser.getId()).isEqualTo(siteUser.getId()); } } diff --git a/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java index 5a32ef362..04b6780ad 100644 --- a/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java @@ -1,24 +1,20 @@ package com.example.solidconnection.auth.service; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.example.solidconnection.auth.dto.EmailSignInRequest; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.crypto.password.PasswordEncoder; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; @DisplayName("이메일 로그인 서비스 테스트") @TestContainerSpringBootTest @@ -28,19 +24,15 @@ class EmailSignInServiceTest { private EmailSignInService emailSignInService; @Autowired - private SiteUserRepository siteUserRepository; - - @Autowired - private PasswordEncoder passwordEncoder; + private SiteUserFixture siteUserFixture; @Test void 로그인에_성공한다() { // given String email = "testEmail"; String rawPassword = "testPassword"; - SiteUser siteUser = createSiteUser(email, rawPassword); - siteUserRepository.save(siteUser); - EmailSignInRequest signInRequest = new EmailSignInRequest(siteUser.getEmail(), rawPassword); + SiteUser user = siteUserFixture.사용자(email, rawPassword); + EmailSignInRequest signInRequest = new EmailSignInRequest(user.getEmail(), rawPassword); // when SignInResponse signInResponse = emailSignInService.signIn(signInRequest); @@ -56,41 +48,27 @@ class EmailSignInServiceTest { class 로그인에_실패한다 { @Test - void 이메일과_일치하는_사용자가_없으면_예외_응답을_반환한다() { + void 이메일과_일치하는_사용자가_없으면_예외가_발생한다() { // given EmailSignInRequest signInRequest = new EmailSignInRequest("이메일", "비밀번호"); // when & then assertThatCode(() -> emailSignInService.signIn(signInRequest)) .isInstanceOf(CustomException.class) - .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + .hasMessageContaining(ErrorCode.SIGN_IN_FAILED.getMessage()); } @Test - void 비밀번호가_일치하지_않으면_예외_응답을_반환한다() { + void 비밀번호가_일치하지_않으면_예외가_발생한다() { // given String email = "testEmail"; - SiteUser siteUser = createSiteUser(email, "testPassword"); - siteUserRepository.save(siteUser); + siteUserFixture.사용자(email, "testPassword"); EmailSignInRequest signInRequest = new EmailSignInRequest(email, "틀린비밀번호"); // when & then assertThatCode(() -> emailSignInService.signIn(signInRequest)) .isInstanceOf(CustomException.class) - .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + .hasMessageContaining(ErrorCode.SIGN_IN_FAILED.getMessage()); } } - - private SiteUser createSiteUser(String email, String rawPassword) { - String encodedPassword = passwordEncoder.encode(rawPassword); - return new SiteUser( - email, - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE, - AuthType.EMAIL, - encodedPassword - ); - } } diff --git a/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java new file mode 100644 index 000000000..62655df2a --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java @@ -0,0 +1,199 @@ +package com.example.solidconnection.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.token.JwtTokenProvider; +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +@DisplayName("토큰 제공자 테스트") +@TestContainerSpringBootTest +class JwtTokenProviderTest { + + @Autowired + private JwtTokenProvider tokenProvider; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private RedisTemplate redisTemplate; + + @Nested + class 토큰을_생성한다 { + + @Test + void subject_만_있는_토큰을_생성한다() { + // given + String actualSubject = "subject123"; + TokenType actualTokenType = TokenType.ACCESS; + + // when + String token = tokenProvider.generateToken(actualSubject, actualTokenType); + + // then - subject와 만료 시간이 일치하는지 검증 + Claims claims = tokenProvider.parseClaims(token); + long expectedExpireTime = claims.getExpiration().getTime() - claims.getIssuedAt().getTime(); + assertAll( + () -> assertThat(claims.getSubject()).isEqualTo(actualSubject), + () -> assertThat(expectedExpireTime).isEqualTo(actualTokenType.getExpireTime()) + ); + } + + @Test + void subject_와_claims_가_있는_토큰을_생성한다() { + // given + String actualSubject = "subject123"; + Map customClaims = Map.of("key1", "value1", "key2", "value2"); + TokenType actualTokenType = TokenType.ACCESS; + + // when + String token = tokenProvider.generateToken(actualSubject, customClaims, actualTokenType); + + // then - subject와 커스텀 클레임이 일치하는지 검증 + Claims claims = tokenProvider.parseClaims(token); + long expectedExpireTime = claims.getExpiration().getTime() - claims.getIssuedAt().getTime(); + assertAll( + () -> assertThat(claims.getSubject()).isEqualTo(actualSubject), + () -> assertThat(claims).containsAllEntriesOf(customClaims), + () -> assertThat(expectedExpireTime).isEqualTo(actualTokenType.getExpireTime()) + ); + } + } + + @Test + void 토큰을_저장한다() { + // given + String subject = "subject123"; + TokenType tokenType = TokenType.ACCESS; + String token = tokenProvider.generateToken(subject, tokenType); + + // when + String savedToken = tokenProvider.saveToken(token, tokenType); + + // then - key: "{TokenType.Prefix}:subject", value: {token} 로 저장되어있는지 검증, 반환하는 값이 value와 같은지 검증 + String key = tokenType.addPrefix(subject); + String value = redisTemplate.opsForValue().get(key); + assertAll( + () -> assertThat(value).isEqualTo(token), + () -> assertThat(savedToken).isEqualTo(value) + ); + } + + @Nested + class 토큰으로부터_subject_를_추출한다 { + + @Test + void 유효한_토큰의_subject_를_추출한다() { + // given + String subject = "subject000"; + String token = createValidToken(subject); + + // when + String extractedSubject = tokenProvider.parseSubject(token); + + // then + assertThat(extractedSubject).isEqualTo(subject); + } + + @Test + void 유효하지_않은_토큰의_subject_를_추출하면_예외가_발생한다() { + // given + String subject = "subject123"; + String token = createExpiredToken(subject); + + // when, then + assertThatCode(() -> tokenProvider.parseSubject(token)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + @Nested + class 토큰으로부터_claim_을_추출한다 { + + @Test + void 유효한_토큰의_claim_을_추출한다() { + // given + String subject = "subject"; + String claimKey = "key"; + String claimValue = "value"; + Claims expectedClaims = Jwts.claims(new HashMap<>(Map.of(claimKey, claimValue))).setSubject(subject); + String token = createValidToken(expectedClaims); + + // when + Claims actualClaims = tokenProvider.parseClaims(token); + + // then + assertAll( + () -> assertThat(actualClaims.getSubject()).isEqualTo(subject), + () -> assertThat(actualClaims.get(claimKey)).isEqualTo(claimValue) + ); + } + + @Test + void 유효하지_않은_토큰의_claim_을_추출하면_예외가_발생한다() { + // given + String subject = "subject"; + Claims expectedClaims = Jwts.claims().setSubject(subject); + String token = createExpiredToken(expectedClaims); + + // when + assertThatCode(() -> tokenProvider.parseClaims(token)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + private String createValidToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createValidToken(Claims claims) { + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createExpiredToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createExpiredToken(Claims claims) { + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/PasswordTemporaryStorageTest.java b/src/test/java/com/example/solidconnection/auth/service/PasswordTemporaryStorageTest.java new file mode 100644 index 000000000..ea3ed6355 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/PasswordTemporaryStorageTest.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; + +@DisplayName("비밀번호 임시 저장소 테스트") +@TestContainerSpringBootTest +class PasswordTemporaryStorageTest { + + @Autowired + private PasswordTemporaryStorage passwordTemporaryStorage; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final String email = "test@email.com"; + private final String rawPassword = "password123"; + + @Test + void 인코딩된_비밀번호를_임시_저장소에_저장하고_조회한다() { + // when + passwordTemporaryStorage.save(email, rawPassword); + Optional foundPassword = passwordTemporaryStorage.findByEmail(email); + + // then + assertThat(foundPassword).isPresent(); + assertThat(passwordEncoder.matches(rawPassword, foundPassword.get())).isTrue(); + } + + @Test + void 임시_저장된_비밀번호를_삭제한다() { + // given + passwordTemporaryStorage.save(email, rawPassword); + + // when + passwordTemporaryStorage.deleteByEmail(email); + Optional foundPassword = passwordTemporaryStorage.findByEmail(email); + + // then + assertThat(foundPassword).isEmpty(); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java index 6136bbee2..da06aa3e4 100644 --- a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java @@ -1,23 +1,19 @@ package com.example.solidconnection.auth.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.auth.domain.TokenType; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.config.security.JwtProperties; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.util.JwtUtils; +import java.time.LocalDate; 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 java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; +import org.springframework.data.redis.core.RedisTemplate; @DisplayName("로그인 서비스 테스트") @TestContainerSpringBootTest @@ -27,59 +23,47 @@ class SignInServiceTest { private SignInService signInService; @Autowired - private JwtProperties jwtProperties; + private TokenProvider tokenProvider; @Autowired - private AuthTokenProvider authTokenProvider; + private RedisTemplate redisTemplate; @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; - private SiteUser siteUser; + private SiteUser user; private String subject; @BeforeEach void setUp() { - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - subject = siteUser.getId().toString(); + user = siteUserFixture.사용자(); + subject = user.getId().toString(); } @Test void 성공적으로_로그인한다() { // when - SignInResponse signInResponse = signInService.signIn(siteUser); + SignInResponse signInResponse = signInService.signIn(user); // then - String accessTokenSubject = JwtUtils.parseSubject(signInResponse.accessToken(), jwtProperties.secret()); - String refreshTokenSubject = JwtUtils.parseSubject(signInResponse.refreshToken(), jwtProperties.secret()); - Optional savedRefreshToken = authTokenProvider.findRefreshToken(subject); + String accessTokenSubject = tokenProvider.parseSubject(signInResponse.accessToken()); + String refreshTokenSubject = tokenProvider.parseSubject(signInResponse.refreshToken()); + String savedRefreshToken = redisTemplate.opsForValue().get(TokenType.REFRESH.addPrefix(refreshTokenSubject)); assertAll( () -> assertThat(accessTokenSubject).isEqualTo(subject), () -> assertThat(refreshTokenSubject).isEqualTo(subject), - () -> assertThat(savedRefreshToken).hasValue(signInResponse.refreshToken())); + () -> assertThat(savedRefreshToken).isEqualTo(signInResponse.refreshToken())); } @Test void 탈퇴한_이력이_있으면_초기화한다() { // given - siteUser.setQuitedAt(LocalDate.now().minusDays(1)); - siteUserRepository.save(siteUser); + user.setQuitedAt(LocalDate.now().minusDays(1)); // when - signInService.signIn(siteUser); + signInService.signIn(user); // then - assertThat(siteUser.getQuitedAt()).isNull(); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); + assertThat(user.getQuitedAt()).isNull(); } } diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java similarity index 59% rename from src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java rename to src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java index 12ab6f666..c75eac5f5 100644 --- a/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java @@ -1,38 +1,38 @@ -package com.example.solidconnection.auth.service.oauth; +package com.example.solidconnection.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.util.JwtUtils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; - -import static com.example.solidconnection.auth.service.oauth.OAuthSignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; -import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - @TestContainerSpringBootTest -@DisplayName("OAuth 회원가입 토큰 제공자 테스트") -class OAuthSignUpTokenProviderTest { +@DisplayName("회원가입 토큰 제공자 테스트") +class SignUpTokenProviderTest { @Autowired - private OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + private SignUpTokenProvider signUpTokenProvider; + + @Autowired + private TokenProvider tokenProvider; @Autowired private RedisTemplate redisTemplate; @@ -40,19 +40,19 @@ class OAuthSignUpTokenProviderTest { @Autowired private JwtProperties jwtProperties; + private final String authTypeClaimKey = "authType"; + private final String email = "test@email.com"; + private final AuthType authType = AuthType.KAKAO; + @Test void 회원가입_토큰을_생성하고_저장한다() { - // given - String email = "email"; - AuthType authType = AuthType.KAKAO; - // when - String signUpToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, authType); + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType); // then - Claims claims = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()); + Claims claims = tokenProvider.parseClaims(signUpToken); String actualSubject = claims.getSubject(); - AuthType actualAuthType = AuthType.valueOf(claims.get(AUTH_TYPE_CLAIM_KEY, String.class)); + AuthType actualAuthType = AuthType.valueOf(claims.get(authTypeClaimKey, String.class)); String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); assertAll( () -> assertThat(actualSubject).isEqualTo(email), @@ -61,75 +61,88 @@ class OAuthSignUpTokenProviderTest { ); } + @Test + void 회원가입_토큰을_삭제한다() { + // given + signUpTokenProvider.generateAndSaveSignUpToken(email, authType); + + // when + signUpTokenProvider.deleteByEmail(email); + + // then + String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); + assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isNull(); + } + @Nested class 주어진_회원가입_토큰을_검증한다 { @Test void 검증_성공한다() { // given - String email = "email@test.com"; - Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + Map claim = new HashMap<>(Map.of(authTypeClaimKey, authType)); String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); // when & then - assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); } @Test - void 만료되었으면_예외_응답을_반환한다() { + void 만료되었으면_예외가_발생한다() { // given String expiredToken = createExpiredToken(); // when & then - assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(expiredToken)) + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(expiredToken)) .isInstanceOf(CustomException.class) .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); } @Test - void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_jwt_가_아닌_토큰() { + void 정해진_형식에_맞지_않으면_예외가_발생한다_jwt_가_아닌_토큰() { // given String notJwt = "not jwt"; // when & then - assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(notJwt)) + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(notJwt)) .isInstanceOf(CustomException.class) .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); } @Test - void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_authType_클래스_불일치() { + void 정해진_형식에_맞지_않으면_예외가_발생한다_authType_클래스_불일치() { // given - Map wrongClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, "카카오")); - String wrongAuthType = createBaseJwtBuilder().addClaims(wrongClaim).compact(); + String wrongAuthType = "카카오"; + Map wrongClaim = new HashMap<>(Map.of(authTypeClaimKey, wrongAuthType)); + String wrongAuthTypeClaim = createBaseJwtBuilder().addClaims(wrongClaim).compact(); // when & then - assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(wrongAuthType)) + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(wrongAuthTypeClaim)) .isInstanceOf(CustomException.class) .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); } @Test - void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_subject_누락() { + void 정해진_형식에_맞지_않으면_예외가_발생한다_subject_누락() { // given - Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + Map claim = new HashMap<>(Map.of(authTypeClaimKey, authType)); String noSubject = createBaseJwtBuilder().addClaims(claim).compact(); // when & then - assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(noSubject)) + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(noSubject)) .isInstanceOf(CustomException.class) .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); } @Test - void 우리_서버에_발급된_토큰이_아니면_예외_응답을_반환한다() { + void 우리_서버에_발급된_토큰이_아니면_예외가_발생한다() { // given - Map validClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); - String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject("email").compact(); + Map validClaim = new HashMap<>(Map.of(authTypeClaimKey, authType)); + String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject(email).compact(); // when & then - assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(signUpToken)) + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(signUpToken)) .isInstanceOf(CustomException.class) .hasMessageContaining(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER.getMessage()); } @@ -138,13 +151,12 @@ class 주어진_회원가입_토큰을_검증한다 { @Test void 회원가입_토큰에서_이메일을_추출한다() { // given - String email = "email@test.com"; - Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE); + Map claim = Map.of(authTypeClaimKey, authType); String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); // when - String extractedEmail = OAuthSignUpTokenProvider.parseEmail(validToken); + String extractedEmail = signUpTokenProvider.parseEmail(validToken); // then assertThat(extractedEmail).isEqualTo(email); @@ -153,20 +165,19 @@ class 주어진_회원가입_토큰을_검증한다 { @Test void 회원가입_토큰에서_인증_타입을_추출한다() { // given - AuthType authType = AuthType.APPLE; - Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, authType); - String validToken = createBaseJwtBuilder().setSubject("email").addClaims(claim).compact(); + Map claim = Map.of(authTypeClaimKey, authType); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); // when - AuthType extractedAuthType = OAuthSignUpTokenProvider.parseAuthType(validToken); + AuthType extractedAuthType = signUpTokenProvider.parseAuthType(validToken); // then assertThat(extractedAuthType).isEqualTo(authType); } - + private String createExpiredToken() { return Jwts.builder() - .setSubject("subject") + .setSubject(email) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() - 1000)) .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) diff --git a/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java new file mode 100644 index 000000000..5267f88f3 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java @@ -0,0 +1,63 @@ +package com.example.solidconnection.auth.service; + +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +@DisplayName("토큰 블랙리스트 서비스 테스트") +@TestContainerSpringBootTest +class TokenBlackListServiceTest { + + @Autowired + private TokenBlackListService tokenBlackListService; + + @Autowired + private RedisTemplate redisTemplate; + + private AccessToken accessToken; + + @BeforeEach + void setUp() { + accessToken = new AccessToken("subject", Role.MENTEE, "token"); + } + + + @Test + void 액세스_토큰을_블랙리스트에_추가한다() { + // when + tokenBlackListService.addToBlacklist(accessToken); + + // then + String blackListTokenKey = BLACKLIST.addPrefix(accessToken.token()); + String foundBlackListToken = redisTemplate.opsForValue().get(blackListTokenKey); + assertThat(foundBlackListToken).isNotNull(); + } + + @Nested + class 블랙리스트에_있는_토큰인지_확인한다 { + + @Test + void 블랙리스트에_토큰이_있는_경우() { + // given + tokenBlackListService.addToBlacklist(accessToken); + + // when, then + assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue(); + } + + @Test + void 블랙리스트에_토큰이_없는_경우() { + // when, then + assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isFalse(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthClientMapTest.java b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthClientMapTest.java new file mode 100644 index 000000000..da4ce868b --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthClientMapTest.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.auth.service.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.siteuser.domain.AuthType; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("OAuthClientMap 테스트") +class OAuthClientMapTest { + + @Test + void AuthType에_해당하는_Client를_반환한다() { + // given + OAuthClient appleClient = mock(OAuthClient.class); + OAuthClient kakaoClient = mock(OAuthClient.class); + given(appleClient.getAuthType()).willReturn(AuthType.APPLE); + given(kakaoClient.getAuthType()).willReturn(AuthType.KAKAO); + + OAuthClientMap oAuthClientMap = new OAuthClientMap( + List.of(appleClient, kakaoClient) + ); + + // when & then + assertAll( + () -> assertThat(oAuthClientMap.getOAuthClient(AuthType.APPLE)).isEqualTo(appleClient), + () -> assertThat(oAuthClientMap.getOAuthClient(AuthType.KAKAO)).isEqualTo(kakaoClient) + ); + } + + @Test + void AuthType에_매칭되는_Client가_없으면_예외가_발생한다() { + // given + OAuthClient appleClient = mock(OAuthClient.class); + given(appleClient.getAuthType()).willReturn(AuthType.APPLE); + + OAuthClientMap oAuthClientMap = new OAuthClientMap( + List.of(appleClient) + ); + + // when & then + assertThatCode(() -> oAuthClientMap.getOAuthClient(AuthType.KAKAO)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.NOT_DEFINED_ERROR.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthServiceTest.java new file mode 100644 index 000000000..427701399 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthServiceTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.auth.service.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +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; + +@DisplayName("OAuth 서비스 테스트") +@TestContainerSpringBootTest +class OAuthServiceTest { + + @Autowired + private OAuthService oAuthService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @MockBean + private OAuthClientMap oauthClientMap; + + private final AuthType authType = AuthType.KAKAO; + private final String oauthCode = "code"; + private final String email = "test@test.com"; + private final String profileImageUrl = "profile.jpg"; + private final String nickname = "testUser"; + + @BeforeEach + void setUp() { // 실제 client 호출하지 않도록 mocking + OAuthUserInfoDto oauthUserInfoDto = mock(OAuthUserInfoDto.class); + given(oauthUserInfoDto.getEmail()).willReturn(email); + given(oauthUserInfoDto.getProfileImageUrl()).willReturn(profileImageUrl); + given(oauthUserInfoDto.getNickname()).willReturn(nickname); + + OAuthClient oAuthClient = mock(OAuthClient.class); + given(oauthClientMap.getOAuthClient(authType)).willReturn(oAuthClient); + given(oAuthClient.getAuthType()).willReturn(authType); + given(oAuthClient.getUserInfo(oauthCode)).willReturn(oauthUserInfoDto); + } + + @Test + void 기존_회원이라면_로그인한다() { + // given + siteUserFixture.사용자(email, authType); + + // when + OAuthResponse response = oAuthService.processOAuth(authType, new OAuthCodeRequest(oauthCode)); + + // then + assertThat(response).isInstanceOf(OAuthSignInResponse.class); + OAuthSignInResponse signInResponse = (OAuthSignInResponse) response; + assertAll( + () -> assertThat(signInResponse.isRegistered()).isTrue(), + () -> assertThat(signInResponse.accessToken()).isNotBlank(), + () -> assertThat(signInResponse.refreshToken()).isNotBlank() + ); + } + + @Test + void 신규_회원이라면_회원가입에_필요한_정보를_응답한다() { + // when + OAuthResponse response = oAuthService.processOAuth(authType, new OAuthCodeRequest(oauthCode)); + + // then + assertThat(response).isInstanceOf(SignUpPrepareResponse.class); + SignUpPrepareResponse signUpPrepareResponse = (SignUpPrepareResponse) response; + assertAll( + () -> assertThat(signUpPrepareResponse.isRegistered()).isFalse(), + () -> assertThat(signUpPrepareResponse.signUpToken()).isNotBlank(), + () -> assertThat(signUpPrepareResponse.email()).isEqualTo(email), + () -> assertThat(signUpPrepareResponse.profileImageUrl()).isEqualTo(profileImageUrl), + () -> assertThat(signUpPrepareResponse.nickname()).isEqualTo(nickname) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixture.java new file mode 100644 index 000000000..37f85c6e9 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixture.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import com.example.solidconnection.chat.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatAttachmentFixture { + + private final ChatAttachmentFixtureBuilder chatAttachmentFixtureBuilder; + + public ChatAttachment 첨부파일(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) { + return chatAttachmentFixtureBuilder.chatAttachment() + .isImage(isImage) + .url(url) + .thumbnailUrl(thumbnailUrl) + .chatMessage(chatMessage) + .create(); + } + + public ChatAttachment 이미지(String url, String thumbnailUrl, ChatMessage chatMessage) { + return 첨부파일(true, url, thumbnailUrl, chatMessage); + } + + public ChatAttachment 파일(String url, ChatMessage chatMessage) { + return 첨부파일(false, url, null, chatMessage); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixtureBuilder.java new file mode 100644 index 000000000..7db17caf0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixtureBuilder.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.repository.ChatAttachmentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatAttachmentFixtureBuilder { + + private final ChatAttachmentRepository chatAttachmentRepository; + + private boolean isImage; + private String url; + private String thumbnailUrl; + private ChatMessage chatMessage; + + public ChatAttachmentFixtureBuilder chatAttachment() { + return new ChatAttachmentFixtureBuilder(chatAttachmentRepository); + } + + public ChatAttachmentFixtureBuilder isImage(boolean isImage) { + this.isImage = isImage; + return this; + } + + public ChatAttachmentFixtureBuilder url(String url) { + this.url = url; + return this; + } + + public ChatAttachmentFixtureBuilder thumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + return this; + } + + public ChatAttachmentFixtureBuilder chatMessage(ChatMessage chatMessage) { + this.chatMessage = chatMessage; + return this; + } + + public ChatAttachment create() { + ChatAttachment attachment = new ChatAttachment(isImage, url, thumbnailUrl, chatMessage); + return chatAttachmentRepository.save(attachment); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixture.java new file mode 100644 index 000000000..f5a30cec8 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixture.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatMessageFixture { + + private final ChatMessageFixtureBuilder chatMessageFixtureBuilder; + + public ChatMessage 메시지(String content, long senderId, ChatRoom chatRoom) { + return chatMessageFixtureBuilder.chatMessage() + .content(content) + .senderId(senderId) + .chatRoom(chatRoom) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixtureBuilder.java new file mode 100644 index 000000000..8b30718cb --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixtureBuilder.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatMessageFixtureBuilder { + + private final ChatMessageRepository chatMessageRepository; + + private String content; + private long senderId; + private ChatRoom chatRoom; + + public ChatMessageFixtureBuilder chatMessage() { + return new ChatMessageFixtureBuilder(chatMessageRepository); + } + + public ChatMessageFixtureBuilder content(String content) { + this.content = content; + return this; + } + + public ChatMessageFixtureBuilder senderId(long senderId) { + this.senderId = senderId; + return this; + } + + public ChatMessageFixtureBuilder chatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + return this; + } + + public ChatMessage create() { + ChatMessage chatMessage = new ChatMessage(content, senderId, chatRoom); + return chatMessageRepository.save(chatMessage); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixture.java new file mode 100644 index 000000000..20825919d --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixture.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatParticipantFixture { + + private final ChatParticipantFixtureBuilder chatParticipantFixtureBuilder; + + public ChatParticipant 참여자(long siteUserId, ChatRoom chatRoom) { + return chatParticipantFixtureBuilder.chatParticipant() + .siteUserId(siteUserId) + .chatRoom(chatRoom) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixtureBuilder.java new file mode 100644 index 000000000..8514ce77e --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixtureBuilder.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatParticipantFixtureBuilder { + + private final ChatParticipantRepository chatParticipantRepository; + + private long siteUserId; + private ChatRoom chatRoom; + + public ChatParticipantFixtureBuilder chatParticipant() { + return new ChatParticipantFixtureBuilder(chatParticipantRepository); + } + + public ChatParticipantFixtureBuilder siteUserId(long siteUserId) { + this.siteUserId = siteUserId; + return this; + } + + public ChatParticipantFixtureBuilder chatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + return this; + } + + public ChatParticipant create() { + ChatParticipant chatParticipant = new ChatParticipant(siteUserId, chatRoom); + return chatParticipantRepository.save(chatParticipant); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixture.java new file mode 100644 index 000000000..f254faaf3 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixture.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatReadStatusFixture { + + private final ChatReadStatusFixtureBuilder chatReadStatusFixtureBuilder; + + public ChatReadStatus 읽음상태(long chatRoomId, long chatParticipantId) { + return chatReadStatusFixtureBuilder.chatReadStatus() + .chatRoomId(chatRoomId) + .chatParticipantId(chatParticipantId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixtureBuilder.java new file mode 100644 index 000000000..6f42c7d13 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixtureBuilder.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import com.example.solidconnection.chat.repository.ChatReadStatusRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatReadStatusFixtureBuilder { + + private final ChatReadStatusRepository chatReadStatusRepository; + + private long chatRoomId; + private long chatParticipantId; + + public ChatReadStatusFixtureBuilder chatReadStatus() { + return new ChatReadStatusFixtureBuilder(chatReadStatusRepository); + } + + public ChatReadStatusFixtureBuilder chatRoomId(long chatRoomId) { + this.chatRoomId = chatRoomId; + return this; + } + + public ChatReadStatusFixtureBuilder chatParticipantId(long chatParticipantId) { + this.chatParticipantId = chatParticipantId; + return this; + } + + public ChatReadStatus create() { + ChatReadStatus chatReadStatus = new ChatReadStatus(chatRoomId, chatParticipantId); + return chatReadStatusRepository.save(chatReadStatus); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixture.java new file mode 100644 index 000000000..717852a4c --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixture.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixture { + + private final ChatRoomFixtureBuilder chatRoomFixtureBuilder; + + public ChatRoom 채팅방(boolean isGroup) { + return chatRoomFixtureBuilder.chatRoom() + .isGroup(isGroup) + .create(); + } + + public ChatRoom 멘토링_채팅방(long mentoringId) { + return chatRoomFixtureBuilder.chatRoom() + .mentoringId(mentoringId) + .isGroup(false) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixtureBuilder.java new file mode 100644 index 000000000..9bbb3e988 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixtureBuilder.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatRoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixtureBuilder { + + private final ChatRoomRepository chatRoomRepository; + + private boolean isGroup; + private Long mentoringId; + + public ChatRoomFixtureBuilder chatRoom() { + return new ChatRoomFixtureBuilder(chatRoomRepository); + } + + public ChatRoomFixtureBuilder isGroup(boolean isGroup) { + this.isGroup = isGroup; + return this; + } + + public ChatRoomFixtureBuilder mentoringId(long mentoringId) { + this.mentoringId = mentoringId; + return this; + } + + public ChatRoom create() { + ChatRoom chatRoom = new ChatRoom(mentoringId, isGroup); + return chatRoomRepository.save(chatRoom); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/repository/ChatReadStatusRepositoryForTest.java b/src/test/java/com/example/solidconnection/chat/repository/ChatReadStatusRepositoryForTest.java new file mode 100644 index 000000000..894276b78 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/repository/ChatReadStatusRepositoryForTest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatReadStatusRepositoryForTest extends JpaRepository { + + Optional findByChatRoomIdAndChatParticipantId(long chatRoomId, long chatParticipantId); +} diff --git a/src/test/java/com/example/solidconnection/chat/repository/ChatRoomRepositoryForTest.java b/src/test/java/com/example/solidconnection/chat/repository/ChatRoomRepositoryForTest.java new file mode 100644 index 000000000..7605453c6 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/repository/ChatRoomRepositoryForTest.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatRoom; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatRoomRepositoryForTest extends JpaRepository { + + @Query(""" + SELECT DISTINCT cr FROM ChatRoom cr + LEFT JOIN FETCH cr.chatParticipants cp + WHERE cr.isGroup = false + AND EXISTS ( + SELECT 1 FROM ChatParticipant cp1 + WHERE cp1.chatRoom = cr AND cp1.siteUserId = :mentorId + ) + AND EXISTS ( + SELECT 1 FROM ChatParticipant cp2 + WHERE cp2.chatRoom = cr AND cp2.siteUserId = :menteeId + ) + AND ( + SELECT COUNT(cp3) FROM ChatParticipant cp3 + WHERE cp3.chatRoom = cr + ) = 2 + """) + Optional findOneOnOneChatRoomByParticipants(@Param("mentorId") long mentorId, @Param("menteeId") long menteeId); +} diff --git a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java new file mode 100644 index 000000000..9f3c1f017 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java @@ -0,0 +1,447 @@ +package com.example.solidconnection.chat.service; + +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTICIPANT_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTNER_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatReadStatus; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.dto.ChatMessageResponse; +import com.example.solidconnection.chat.dto.ChatMessageSendRequest; +import com.example.solidconnection.chat.dto.ChatMessageSendResponse; +import com.example.solidconnection.chat.dto.ChatParticipantResponse; +import com.example.solidconnection.chat.dto.ChatRoomListResponse; +import com.example.solidconnection.chat.fixture.ChatAttachmentFixture; +import com.example.solidconnection.chat.fixture.ChatMessageFixture; +import com.example.solidconnection.chat.fixture.ChatParticipantFixture; +import com.example.solidconnection.chat.fixture.ChatReadStatusFixture; +import com.example.solidconnection.chat.fixture.ChatRoomFixture; +import com.example.solidconnection.chat.repository.ChatReadStatusRepositoryForTest; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +@TestContainerSpringBootTest +@DisplayName("채팅 서비스 테스트") +class ChatServiceTest { + + @Autowired + private ChatService chatService; + + @Autowired + private ChatReadStatusRepositoryForTest chatReadStatusRepositoryForTest; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Autowired + private ChatParticipantFixture chatParticipantFixture; + + @Autowired + private ChatMessageFixture chatMessageFixture; + + @Autowired + private ChatReadStatusFixture chatReadStatusFixture; + + @Autowired + private ChatAttachmentFixture chatAttachmentFixture; + + @MockBean + private SimpMessagingTemplate simpMessagingTemplate; + + private SiteUser user; + private SiteUser mentor1; + private SiteUser mentor2; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + mentor1 = siteUserFixture.사용자(1, "mentor1"); + mentor2 = siteUserFixture.사용자(2, "mentor2"); + } + + @Nested + class 채팅방_목록을_조회한다 { + + @Test + void 채팅방이_없으면_빈_목록을_반환한다() { + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertThat(response.chatRooms()).isEmpty(); + } + + @Test + void 최신_메시지_순으로_정렬되어_조회한다() { + // given + ChatRoom chatRoom1 = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom1); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom1); + ChatMessage oldMessage = chatMessageFixture.메시지("오래된 메시지", mentor1.getId(), chatRoom1); + + ChatRoom chatRoom2 = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom2); + chatParticipantFixture.참여자(mentor2.getId(), chatRoom2); + ChatMessage newMessage = chatMessageFixture.메시지("최신 메시지", mentor2.getId(), chatRoom2); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertAll( + () -> assertThat(response.chatRooms()).hasSize(2), + () -> assertThat(response.chatRooms().get(0).partner().partnerId()).isEqualTo(mentor2.getId()), + () -> assertThat(response.chatRooms().get(0).lastChatMessage()).isEqualTo(newMessage.getContent()), + () -> assertThat(response.chatRooms().get(1).partner().partnerId()).isEqualTo(mentor1.getId()), + () -> assertThat(response.chatRooms().get(1).lastChatMessage()).isEqualTo(oldMessage.getContent()) + ); + } + + @Test + void 그룹_채팅방은_제외하고_1대1_채팅방만_조회한다() { + // given + ChatRoom oneOnOneRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), oneOnOneRoom); + chatParticipantFixture.참여자(mentor1.getId(), oneOnOneRoom); + + ChatRoom groupRoom = chatRoomFixture.채팅방(true); + chatParticipantFixture.참여자(user.getId(), groupRoom); + chatParticipantFixture.참여자(mentor1.getId(), groupRoom); + chatParticipantFixture.참여자(mentor2.getId(), groupRoom); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertAll( + () -> assertThat(response.chatRooms()).hasSize(1), + () -> assertThat(response.chatRooms().get(0).id()).isEqualTo(oneOnOneRoom.getId()) + ); + } + + @Test + void 채팅_상대방이_없으면_예외가_발생한다() { + // given + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + + // when & then + assertThatCode(() -> chatService.getChatRooms(user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTNER_NOT_FOUND.getMessage()); + } + } + + @Nested + class 읽지_않은_메시지_수를_조회한다 { + + private ChatRoom chatRoom; + private ChatParticipant participant; + + @BeforeEach + void setUp() { + chatRoom = chatRoomFixture.채팅방(false); + participant = chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + } + + @Test + void 읽음_상태가_없으면_모든_상대방_메시지를_카운팅한다() { + // given + chatMessageFixture.메시지("메시지1", mentor1.getId(), chatRoom); + chatMessageFixture.메시지("메시지2", mentor1.getId(), chatRoom); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertThat(response.chatRooms().get(0).unReadCount()).isEqualTo(2); + } + + @Test + void 읽음_상태_이후_메시지만_읽지_않은_메시지로_카운팅한다() { + // given + chatMessageFixture.메시지("읽은 메시지", mentor1.getId(), chatRoom); + chatReadStatusFixture.읽음상태(chatRoom.getId(), participant.getId()); + + chatMessageFixture.메시지("읽지 않은 메시지1", mentor1.getId(), chatRoom); + chatMessageFixture.메시지("읽지 않은 메시지2", mentor1.getId(), chatRoom); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertThat(response.chatRooms().get(0).unReadCount()).isEqualTo(2); + } + } + + @Nested + class 채팅_메시지를_조회한다 { + + private static final int NO_NEXT_PAGE_NUMBER = -1; + + private ChatRoom chatRoom; + private Pageable pageable; + + @BeforeEach + void setUp() { + chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + + pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + } + + @Test + void 메시지가_없는_채팅방에서_빈_목록을_반환한다() { + // when + SliceResponse response = chatService.getChatMessages(user.getId(), chatRoom.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).isEmpty(), + () -> assertThat(response.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER) + ); + } + + @Test + void 첨부파일이_없는_메시지들을_정상_조회한다() { + // given + ChatMessage message1 = chatMessageFixture.메시지("메시지1", mentor1.getId(), chatRoom); + ChatMessage message2 = chatMessageFixture.메시지("메시지2", user.getId(), chatRoom); + + // when + SliceResponse response = chatService.getChatMessages(user.getId(), chatRoom.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).hasSize(2), + () -> assertThat(response.content().get(0).content()).isEqualTo(message2.getContent()), + () -> assertThat(response.content().get(0).senderId()).isEqualTo(user.getId()), + () -> assertThat(response.content().get(1).content()).isEqualTo(message1.getContent()), + () -> assertThat(response.content().get(1).senderId()).isEqualTo(mentor1.getId()) + ); + } + + @Test + void 첨부파일이_있는_메시지를_정상_조회한다() { + // given + ChatMessage messageWithImage = chatMessageFixture.메시지("이미지", mentor1.getId(), chatRoom); + ChatAttachment imageAttachment = chatAttachmentFixture.첨부파일( + true, + "https://example.com/image.png", + "https://example.com/thumb.png", + messageWithImage + ); + + // when + SliceResponse response = chatService.getChatMessages(user.getId(), chatRoom.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).hasSize(1), + () -> assertThat(response.content().get(0).content()).isEqualTo(messageWithImage.getContent()), + () -> assertThat(response.content().get(0).attachments()).hasSize(1), + () -> assertThat(response.content().get(0).attachments().get(0).id()).isEqualTo(imageAttachment.getId()) + ); + } + + @Test + void 페이징이_정상_작동한다() { + for (int i = 1; i <= 25; i++) { + chatMessageFixture.메시지("메시지" + i, (i % 2 == 0) ? user.getId() : mentor1.getId(), chatRoom); + } + + Pageable firstPage = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Pageable secondPage = PageRequest.of(1, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + SliceResponse firstResponse = chatService.getChatMessages(user.getId(), chatRoom.getId(), firstPage); + SliceResponse secondResponse = chatService.getChatMessages(user.getId(), chatRoom.getId(), secondPage); + + // then + assertAll( + () -> assertThat(firstResponse.nextPageNumber()).isEqualTo(2), + () -> assertThat(secondResponse.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER) + ); + } + + @Test + void 채팅방_참여자가_아니면_예외가_발생한다() { + // when & then + assertThatCode(() -> chatService.getChatMessages(mentor2.getId(), chatRoom.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + + @Test + void 존재하지_않는_채팅방에_접근하면_예외가_발생한다() { + // given + long nonExistentRoomId = 999L; + + // when & then + assertThatCode(() -> chatService.getChatMessages(user.getId(), nonExistentRoomId, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 채팅방_파트너_정보를_조회한다 { + + @Test + void 채팅방_파트너를_정상_조회한다() { + // given + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + + // when + ChatParticipantResponse response = chatService.getChatPartner(user.getId(), chatRoom.getId()); + + // then + assertAll( + () -> assertThat(response.partnerId()).isEqualTo(mentor1.getId()), + () -> assertThat(response.nickname()).isEqualTo(mentor1.getNickname()), + () -> assertThat(response.profileUrl()).isEqualTo(mentor1.getProfileImageUrl()) + ); + } + } + + @Nested + class 채팅_메시지_읽음을_처리한다 { + + private ChatRoom chatRoom; + private ChatParticipant participant; + + @BeforeEach + void setUp() { + chatRoom = chatRoomFixture.채팅방(false); + participant = chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + } + + @Test + void 처음_읽음_처리_시_새로운_읽음_상태를_생성한다() { + // given + chatMessageFixture.메시지("읽지 않은 메시지1", mentor1.getId(), chatRoom); + chatMessageFixture.메시지("읽지 않은 메시지2", mentor1.getId(), chatRoom); + + // when + chatService.markChatMessagesAsRead(user.getId(), chatRoom.getId()); + + // then + ChatReadStatus afterStatus = chatReadStatusRepositoryForTest + .findByChatRoomIdAndChatParticipantId(chatRoom.getId(), participant.getId()) + .orElseThrow(); + + assertThat(afterStatus.getChatRoomId()).isEqualTo(chatRoom.getId()); + } + + @Test + void 기존_읽음_상태가_있으면_updatedAt을_갱신한다() { + // given + ChatReadStatus chatReadStatus = chatReadStatusFixture.읽음상태(chatRoom.getId(), participant.getId()); + ZonedDateTime updatedAt = chatReadStatus.getUpdatedAt(); + chatMessageFixture.메시지("새로운 메시지", mentor1.getId(), chatRoom); + + // when + chatService.markChatMessagesAsRead(user.getId(), chatRoom.getId()); + + // then + ChatReadStatus updatedStatus = chatReadStatusRepositoryForTest + .findByChatRoomIdAndChatParticipantId(chatRoom.getId(), participant.getId()) + .orElseThrow(); + assertAll( + () -> assertThat(updatedStatus.getId()).isEqualTo(chatReadStatus.getId()), + () -> assertThat(updatedStatus.getUpdatedAt()).isAfter(updatedAt) + ); + } + + @Test + void 채팅방_참여자가_아니면_예외가_발생한다() { + // given + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + + // when & then + assertThatCode(() -> chatService.markChatMessagesAsRead(mentor2.getId(), chatRoom.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 채팅_메시지를_전송한다 { + + private SiteUser sender; + private ChatParticipant senderParticipant; + private ChatRoom chatRoom; + + @BeforeEach + void setUp() { + sender = siteUserFixture.사용자(111, "sender"); + chatRoom = chatRoomFixture.채팅방(false); + senderParticipant = chatParticipantFixture.참여자(sender.getId(), chatRoom); + } + + @Test + void 채팅방_참여자는_메시지를_전송할_수_있다() { + // given + final String content = "안녕하세요"; + ChatMessageSendRequest request = new ChatMessageSendRequest(content); + + // when + chatService.sendChatMessage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + + BDDMockito.verify(simpMessagingTemplate).convertAndSend(destinationCaptor.capture(), payloadCaptor.capture()); + + assertAll( + () -> assertThat(destinationCaptor.getValue()).isEqualTo("/topic/chat/" + chatRoom.getId()), + () -> assertThat(payloadCaptor.getValue().content()).isEqualTo(content), + () -> assertThat(payloadCaptor.getValue().senderId()).isEqualTo(senderParticipant.getId()) + ); + } + + @Test + void 채팅_참여자가_아니면_메시지를_전송할_수_없다() { + // given + SiteUser nonParticipant = siteUserFixture.사용자(333, "nonParticipant"); + ChatMessageSendRequest request = new ChatMessageSendRequest("안녕하세요"); + + // when & then + assertThatCode(() -> chatService.sendChatMessage(request, nonParticipant.getId(), chatRoom.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java b/src/test/java/com/example/solidconnection/common/exception/CustomAccessDeniedHandlerTest.java similarity index 84% rename from src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java rename to src/test/java/com/example/solidconnection/common/exception/CustomAccessDeniedHandlerTest.java index 7e4cae5b2..f439621f7 100644 --- a/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java +++ b/src/test/java/com/example/solidconnection/common/exception/CustomAccessDeniedHandlerTest.java @@ -1,8 +1,12 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; -import com.example.solidconnection.custom.response.ErrorResponse; +import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.common.response.ErrorResponse; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,11 +15,6 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.access.AccessDeniedException; -import java.io.IOException; - -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; -import static org.assertj.core.api.Assertions.assertThat; - @TestContainerSpringBootTest @DisplayName("커스텀 인가 예외 처리 테스트") class CustomAccessDeniedHandlerTest { @@ -36,7 +35,7 @@ void setUp() { } @Test - void 권한이_없는_사용자_접근시_403_예외_응답을_반환한다() throws IOException { + void 권한이_없는_사용자_접근시_403_예외가_발생한다() throws IOException { // given AccessDeniedException accessDeniedException = new AccessDeniedException(ACCESS_DENIED.getMessage()); diff --git a/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java b/src/test/java/com/example/solidconnection/common/exception/CustomAuthenticationEntryPointTest.java similarity index 85% rename from src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java rename to src/test/java/com/example/solidconnection/common/exception/CustomAuthenticationEntryPointTest.java index 2cef64481..4636ccca2 100644 --- a/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java +++ b/src/test/java/com/example/solidconnection/common/exception/CustomAuthenticationEntryPointTest.java @@ -1,8 +1,12 @@ -package com.example.solidconnection.custom.exception; +package com.example.solidconnection.common.exception; -import com.example.solidconnection.custom.response.ErrorResponse; +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.common.response.ErrorResponse; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,11 +16,6 @@ import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.AuthenticationException; -import java.io.IOException; - -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; -import static org.assertj.core.api.Assertions.assertThat; - @TestContainerSpringBootTest @DisplayName("커스텀 인증 예외 처리 테스트") class CustomAuthenticationEntryPointTest { @@ -37,7 +36,7 @@ void setUp() { } @Test - void 인증되지_않은_사용자_접근시_401_예외_응답을_반환한다() throws IOException { + void 인증되지_않은_사용자_접근시_401_예외가_발생한다() throws IOException { // given AuthenticationException authException = new AuthenticationServiceException(AUTHENTICATION_FAILED.getMessage()); diff --git a/src/test/java/com/example/solidconnection/common/resolver/AuthorizedUserResolverTest.java b/src/test/java/com/example/solidconnection/common/resolver/AuthorizedUserResolverTest.java new file mode 100644 index 000000000..b53be9c79 --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/resolver/AuthorizedUserResolverTest.java @@ -0,0 +1,124 @@ +package com.example.solidconnection.common.resolver; + +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.lang.reflect.Method; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@TestContainerSpringBootTest +@DisplayName("인증된 사용자 argument resolver 테스트") +class AuthorizedUserResolverTest { + + @Autowired + private AuthorizedUserResolver authorizedUserResolver; + + @Autowired + private SiteUserFixture siteUserFixture; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void security_context_에_저장된_인증된_사용자를_반환한다() { + // given + SiteUser user = siteUserFixture.사용자(); + Authentication authentication = createAuthenticationWithUser(user); + SecurityContextHolder.getContext().setAuthentication(authentication); + + MethodParameter parameter = getTestMethodParameter("method", Long.class); + + // when + Long resolvedUserId = (Long) authorizedUserResolver.resolveArgument(parameter, null, null, null); + + // then + assertAll( + () -> assertThat(resolvedUserId).isNotNull(), + () -> assertThat(resolvedUserId).isEqualTo(user.getId()) + ); + } + + @Nested + class security_context_에_저장된_사용자가_없는_경우 { + + @Test + void 파라미터가_원시값이면_예외가_발생한다() { + // given + MethodParameter primitiveTypeParameter = getTestMethodParameter("primitiveType", long.class); + + // when, then + assertThatCode(() -> authorizedUserResolver.resolveArgument(primitiveTypeParameter, null, null, null)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + + @Test + void required_가_true_이면_예외가_발생한다() { + // given + MethodParameter parameter = getTestMethodParameter("required", Long.class); + + // when, then + assertThatCode(() -> authorizedUserResolver.resolveArgument(parameter, null, null, null)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + + @Test + void required_가_false_이면_null_을_반환한다() { + // given + MethodParameter parameter = getTestMethodParameter("notRequired", Long.class); + + // when, then + assertThat( + authorizedUserResolver.resolveArgument(parameter, null, null, null) + ).isNull(); + } + } + + private TokenAuthentication createAuthenticationWithUser(SiteUser siteUser) { + SiteUserDetails userDetails = new SiteUserDetails(siteUser); + return new TokenAuthentication("token", userDetails); + } + + private MethodParameter getTestMethodParameter(String methodName, Class parameterType) { + // 테스트의 목적을 불분명히 만들 수 있는 throws 절을 제거하기 위해 uncheckedException 로 변환한다. + try { + Method method = TestController.class.getMethod(methodName, parameterType); + return new MethodParameter(method, 0); + } catch (NoSuchMethodException e) { + throw new RuntimeException("Method not found: " + methodName, e); + } + } + + static class TestController { + + public void method(@AuthorizedUser Long userId) { + } + + public void primitiveType(@AuthorizedUser long userId) { + } + + public void required(@AuthorizedUser(required = true) Long userId) { + } + + public void notRequired(@AuthorizedUser(required = false) Long userId) { + } + } +} diff --git a/src/test/java/com/example/solidconnection/custom/resolver/CustomPageableHandlerMethodArgumentResolverTest.java b/src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java similarity index 98% rename from src/test/java/com/example/solidconnection/custom/resolver/CustomPageableHandlerMethodArgumentResolverTest.java rename to src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java index dc628bc67..da4fcc852 100644 --- a/src/test/java/com/example/solidconnection/custom/resolver/CustomPageableHandlerMethodArgumentResolverTest.java +++ b/src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java @@ -1,6 +1,10 @@ -package com.example.solidconnection.custom.resolver; +package com.example.solidconnection.common.resolver; + +import static org.assertj.core.api.Assertions.assertThat; import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.lang.reflect.Method; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,11 +18,6 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.ServletWebRequest; -import java.lang.reflect.Method; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; - @TestContainerSpringBootTest @DisplayName("커스텀 페이지 요청 argument resolver 테스트") class CustomPageableHandlerMethodArgumentResolverTest { diff --git a/src/test/java/com/example/solidconnection/community/board/fixture/BoardFixture.java b/src/test/java/com/example/solidconnection/community/board/fixture/BoardFixture.java new file mode 100644 index 000000000..23815cf02 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/board/fixture/BoardFixture.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.community.board.fixture; + +import static com.example.solidconnection.community.board.domain.BoardCode.AMERICAS; +import static com.example.solidconnection.community.board.domain.BoardCode.ASIA; +import static com.example.solidconnection.community.board.domain.BoardCode.EUROPE; +import static com.example.solidconnection.community.board.domain.BoardCode.FREE; + +import com.example.solidconnection.community.board.domain.Board; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class BoardFixture { + + private final BoardFixtureBuilder boardFixtureBuilder; + + public Board 미주권() { + return boardFixtureBuilder.code(AMERICAS.name()) + .koreanName("미주권") + .findOrCreate(); + } + + public Board 아시아권() { + return boardFixtureBuilder.code(ASIA.name()) + .koreanName("아시아권") + .findOrCreate(); + } + + public Board 유럽권() { + return boardFixtureBuilder.code(EUROPE.name()) + .koreanName("유럽권") + .findOrCreate(); + } + + public Board 자유게시판() { + return boardFixtureBuilder.code(FREE.name()) + .koreanName("자유게시판") + .findOrCreate(); + } +} diff --git a/src/test/java/com/example/solidconnection/community/board/fixture/BoardFixtureBuilder.java b/src/test/java/com/example/solidconnection/community/board/fixture/BoardFixtureBuilder.java new file mode 100644 index 000000000..0a50b0930 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/board/fixture/BoardFixtureBuilder.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.community.board.fixture; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepositoryForTest; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class BoardFixtureBuilder { + + private final BoardRepositoryForTest boardRepositoryForTest; + + private String code; + private String koreanName; + + public BoardFixtureBuilder code(String code) { + this.code = code; + return this; + } + + public BoardFixtureBuilder koreanName(String koreanName) { + this.koreanName = koreanName; + return this; + } + + public Board findOrCreate() { + return boardRepositoryForTest.findByCode(code) + .orElseGet(() -> boardRepositoryForTest.save(new Board(code, koreanName))); + } +} diff --git a/src/test/java/com/example/solidconnection/community/board/repository/BoardRepositoryForTest.java b/src/test/java/com/example/solidconnection/community/board/repository/BoardRepositoryForTest.java new file mode 100644 index 000000000..c27d62452 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/board/repository/BoardRepositoryForTest.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.community.board.repository; + +import com.example.solidconnection.community.board.domain.Board; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface BoardRepositoryForTest extends JpaRepository { + + @Query("SELECT b FROM Board b WHERE b.code = :code") + Optional findByCode(@Param("code") String code); +} diff --git a/src/test/java/com/example/solidconnection/community/comment/fixture/CommentFixture.java b/src/test/java/com/example/solidconnection/community/comment/fixture/CommentFixture.java new file mode 100644 index 000000000..4d0f3b438 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/comment/fixture/CommentFixture.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.community.comment.fixture; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class CommentFixture { + + private final CommentFixtureBuilder commentFixtureBuilder; + + public Comment 부모_댓글(String content, Post post, SiteUser siteUser) { + return commentFixtureBuilder + .content(content) + .post(post) + .siteUser(siteUser) + .createParent(); + } + + public Comment 자식_댓글( + String content, + Post post, + SiteUser siteUser, + Comment parentComment) { + return commentFixtureBuilder + .content(content) + .post(post) + .siteUser(siteUser) + .parentComment(parentComment) + .createChild(); + } +} diff --git a/src/test/java/com/example/solidconnection/community/comment/fixture/CommentFixtureBuilder.java b/src/test/java/com/example/solidconnection/community/comment/fixture/CommentFixtureBuilder.java new file mode 100644 index 000000000..02a8ba889 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/comment/fixture/CommentFixtureBuilder.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.community.comment.fixture; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class CommentFixtureBuilder { + + private final CommentRepository commentRepository; + + private String content; + private Post post; + private SiteUser siteUser; + private Comment parentComment; + + public CommentFixtureBuilder content(String content) { + this.content = content; + return this; + } + + public CommentFixtureBuilder post(Post post) { + this.post = post; + return this; + } + + public CommentFixtureBuilder siteUser(SiteUser siteUser) { + this.siteUser = siteUser; + return this; + } + + public CommentFixtureBuilder parentComment(Comment parentComment) { + this.parentComment = parentComment; + return this; + } + + public Comment createParent() { + Comment comment = new Comment(content); + comment.setPostAndSiteUserId(post, siteUser.getId()); + return commentRepository.save(comment); + } + + public Comment createChild() { + Comment comment = new Comment(content); + comment.setPostAndSiteUserId(post, siteUser.getId()); + comment.setParentCommentAndPostAndSiteUserId(parentComment, post, siteUser.getId()); + return commentRepository.save(comment); + } +} diff --git a/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java index ee74bb90b..c5219b036 100644 --- a/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java @@ -1,6 +1,15 @@ package com.example.solidconnection.community.comment.service; -import com.example.solidconnection.community.board.domain.Board; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_COMMENT_ID; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_COMMENT_LEVEL; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.community.board.fixture.BoardFixture; import com.example.solidconnection.community.comment.domain.Comment; import com.example.solidconnection.community.comment.dto.CommentCreateRequest; import com.example.solidconnection.community.comment.dto.CommentCreateResponse; @@ -8,31 +17,26 @@ import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.comment.fixture.CommentFixture; import com.example.solidconnection.community.comment.repository.CommentRepository; import com.example.solidconnection.community.post.domain.Post; -import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.fixture.PostFixture; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; import jakarta.transaction.Transactional; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_ID; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_LEVEL; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; - +@TestContainerSpringBootTest @DisplayName("댓글 서비스 테스트") -class CommentServiceTest extends BaseIntegrationTest { +class CommentServiceTest { @Autowired private CommentService commentService; @@ -41,7 +45,34 @@ class CommentServiceTest extends BaseIntegrationTest { private CommentRepository commentRepository; @Autowired - private PostRepository postRepository; + private SiteUserFixture siteUserFixture; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private CommentFixture commentFixture; + + private SiteUser user1; + private SiteUser user2; + private Post post; + + @BeforeEach + void setUp() { + user1 = siteUserFixture.사용자(1, "test1"); + user2 = siteUserFixture.사용자(2, "test2"); + post = postFixture.게시글( + "제목1", + "내용1", + false, + PostCategory.자유, + boardFixture.자유게시판(), + user1 + ); + } @Nested class 댓글_조회_테스트 { @@ -49,16 +80,12 @@ class 댓글_조회_테스트 { @Test void 게시글의_모든_댓글을_조회한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); - Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment = commentFixture.자식_댓글("자식 댓글 1", post, user2, parentComment); List comments = List.of(parentComment, childComment); // when - List responses = commentService.findCommentsByPostId( - 테스트유저_1, - testPost.getId() - ); + List responses = commentService.findCommentsByPostId(user1.getId(), post.getId()); // then assertAll( @@ -69,17 +96,7 @@ class 댓글_조회_테스트 { .satisfies(response -> assertAll( () -> assertThat(response.id()).isEqualTo(parentComment.getId()), () -> assertThat(response.parentId()).isNull(), - () -> assertThat(response.content()).isEqualTo(parentComment.getContent()), - () -> assertThat(response.isOwner()).isTrue(), - () -> assertThat(response.createdAt()).isEqualTo(parentComment.getCreatedAt()), - () -> assertThat(response.updatedAt()).isEqualTo(parentComment.getUpdatedAt()), - - () -> assertThat(response.postFindSiteUserResponse().id()) - .isEqualTo(parentComment.getSiteUser().getId()), - () -> assertThat(response.postFindSiteUserResponse().nickname()) - .isEqualTo(parentComment.getSiteUser().getNickname()), - () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()) - .isEqualTo(parentComment.getSiteUser().getProfileImageUrl()) + () -> assertThat(response.isOwner()).isTrue() )), () -> assertThat(responses) .filteredOn(response -> response.id().equals(childComment.getId())) @@ -87,20 +104,89 @@ class 댓글_조회_테스트 { .satisfies(response -> assertAll( () -> assertThat(response.id()).isEqualTo(childComment.getId()), () -> assertThat(response.parentId()).isEqualTo(parentComment.getId()), - () -> assertThat(response.content()).isEqualTo(childComment.getContent()), - () -> assertThat(response.isOwner()).isFalse(), - () -> assertThat(response.createdAt()).isEqualTo(childComment.getCreatedAt()), - () -> assertThat(response.updatedAt()).isEqualTo(childComment.getUpdatedAt()), - - () -> assertThat(response.postFindSiteUserResponse().id()) - .isEqualTo(childComment.getSiteUser().getId()), - () -> assertThat(response.postFindSiteUserResponse().nickname()) - .isEqualTo(childComment.getSiteUser().getNickname()), - () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()) - .isEqualTo(childComment.getSiteUser().getProfileImageUrl()) + () -> assertThat(response.isOwner()).isFalse() )) ); } + + @Test + void 부모댓글과_대댓글이_모두_삭제되면_응답에서_제외한다() { + // given + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment1 = commentFixture.자식_댓글("자식 댓글1", post, user2, parentComment); + Comment childComment2 = commentFixture.자식_댓글("자식 댓글2", post, user2, parentComment); + + parentComment.deprecateComment(); + childComment1.deprecateComment(); + childComment2.deprecateComment(); + commentRepository.saveAll(List.of(parentComment, childComment1, childComment2)); + + // when + List responses = commentService.findCommentsByPostId(user1.getId(), post.getId()); + + // then + assertAll( + () -> assertThat(responses).isEmpty() + ); + } + + @Test + void 부모댓글이_삭제된_경우에도_자식댓글이_존재하면_자식댓글의_내용만_반환한다() { + // given + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment1 = commentFixture.자식_댓글("자식 댓글1", post, user2, parentComment); + Comment childComment2 = commentFixture.자식_댓글("자식 댓글2", post, user2, parentComment); + + parentComment.deprecateComment(); + commentRepository.saveAll(List.of(parentComment, childComment1, childComment2)); + + // when + List responses = commentService.findCommentsByPostId(user1.getId(), post.getId()); + + // then + assertAll( + () -> assertThat(responses).hasSize(3), + () -> assertThat(responses) + .extracting(PostFindCommentResponse::id) + .containsExactlyInAnyOrder(parentComment.getId(), childComment1.getId(), childComment2.getId()), + () -> assertThat(responses) + .filteredOn(response -> response.id().equals(parentComment.getId())) + .extracting(PostFindCommentResponse::content) + .containsExactly(""), + () -> assertThat(responses) + .filteredOn(response -> !response.id().equals(parentComment.getId())) + .extracting(PostFindCommentResponse::content) + .containsExactlyInAnyOrder("자식 댓글1", "자식 댓글2") + ); + } + + @Test + void 부모댓글이_삭제된_경우_부모댓글의_사용자정보는_null이고_자식댓글의_사용자정보는_정상적으로_반환한다() { + // given + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment1 = commentFixture.자식_댓글("자식 댓글1", post, user2, parentComment); + Comment childComment2 = commentFixture.자식_댓글("자식 댓글2", post, user2, parentComment); + + parentComment.deprecateComment(); + commentRepository.saveAll(List.of(parentComment, childComment1, childComment2)); + + // when + List responses = commentService.findCommentsByPostId(user1.getId(), post.getId()); + + // then + assertAll( + () -> assertThat(responses) + .filteredOn(response -> response.id().equals(parentComment.getId())) + .extracting(PostFindCommentResponse::postFindSiteUserResponse) + .containsExactly((PostFindSiteUserResponse) null), + () -> assertThat(responses) + .filteredOn(response -> !response.id().equals(parentComment.getId())) + .extracting(PostFindCommentResponse::postFindSiteUserResponse) + .isNotNull() + .extracting(PostFindSiteUserResponse::id) + .containsExactlyInAnyOrder(user2.getId(), user2.getId()) + ); + } } @Nested @@ -109,14 +195,10 @@ class 댓글_생성_테스트 { @Test void 댓글을_성공적으로_생성한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 댓글", null); + CommentCreateRequest request = new CommentCreateRequest(post.getId(), "댓글", null); // when - CommentCreateResponse response = commentService.createComment( - 테스트유저_1, - request - ); + CommentCreateResponse response = commentService.createComment(user1.getId(), request); // then Comment savedComment = commentRepository.findById(response.id()).orElseThrow(); @@ -124,23 +206,19 @@ class 댓글_생성_테스트 { () -> assertThat(savedComment.getId()).isEqualTo(response.id()), () -> assertThat(savedComment.getContent()).isEqualTo(request.content()), () -> assertThat(savedComment.getParentComment()).isNull(), - () -> assertThat(savedComment.getPost().getId()).isEqualTo(testPost.getId()), - () -> assertThat(savedComment.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + () -> assertThat(savedComment.getPost().getId()).isEqualTo(post.getId()), + () -> assertThat(savedComment.getSiteUserId()).isEqualTo(user1.getId()) ); } @Test void 대댓글을_성공적으로_생성한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); - CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 대댓글", parentComment.getId()); + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + CommentCreateRequest request = new CommentCreateRequest(post.getId(), "자식 댓글", parentComment.getId()); // when - CommentCreateResponse response = commentService.createComment( - 테스트유저_2, - request - ); + CommentCreateResponse response = commentService.createComment(user2.getId(), request); // then Comment savedComment = commentRepository.findById(response.id()).orElseThrow(); @@ -148,42 +226,40 @@ class 댓글_생성_테스트 { () -> assertThat(savedComment.getId()).isEqualTo(response.id()), () -> assertThat(savedComment.getContent()).isEqualTo(request.content()), () -> assertThat(savedComment.getParentComment().getId()).isEqualTo(parentComment.getId()), - () -> assertThat(savedComment.getPost().getId()).isEqualTo(testPost.getId()), - () -> assertThat(savedComment.getSiteUser().getId()).isEqualTo(테스트유저_2.getId()) + () -> assertThat(savedComment.getPost().getId()).isEqualTo(post.getId()), + () -> assertThat(savedComment.getSiteUserId()).isEqualTo(user2.getId()) ); } @Test - void 대대댓글_생성_시도하면_예외_응답을_반환한다() { + void 대대댓글_생성_시도하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); - Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); - CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 대대댓글", childComment.getId()); + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment = commentFixture.자식_댓글("자식 댓글", post, user2, parentComment); + CommentCreateRequest request = new CommentCreateRequest(post.getId(), "대대댓글", childComment.getId()); // when & then assertThatThrownBy(() -> - commentService.createComment( - 테스트유저_1, - request - )) + commentService.createComment( + user1.getId(), + request + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_COMMENT_LEVEL.getMessage()); } @Test - void 존재하지_않는_부모댓글로_대댓글_작성시_예외를_반환한다() { + void 존재하지_않는_부모댓글로_대댓글_작성시_예외가_빌생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); long invalidCommentId = 9999L; - CommentCreateRequest request = new CommentCreateRequest(testPost.getId(), "테스트 대댓글", invalidCommentId); + CommentCreateRequest request = new CommentCreateRequest(post.getId(), "자식 댓글", invalidCommentId); // when & then assertThatThrownBy(() -> - commentService.createComment( - 테스트유저_1, - request - )) + commentService.createComment( + user1.getId(), + request + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_COMMENT_ID.getMessage()); } @@ -195,16 +271,11 @@ class 댓글_수정_테스트 { @Test void 댓글을_성공적으로_수정한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment comment = createComment(testPost, 테스트유저_1, "원본 댓글"); + Comment comment = commentFixture.부모_댓글("원본 댓글", post, user1); CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); // when - CommentUpdateResponse response = commentService.updateComment( - 테스트유저_1, - comment.getId(), - request - ); + CommentUpdateResponse response = commentService.updateComment(user1.getId(), comment.getId(), request); // then Comment updatedComment = commentRepository.findById(response.id()).orElseThrow(); @@ -212,43 +283,41 @@ class 댓글_수정_테스트 { () -> assertThat(updatedComment.getId()).isEqualTo(comment.getId()), () -> assertThat(updatedComment.getContent()).isEqualTo(request.content()), () -> assertThat(updatedComment.getParentComment()).isNull(), - () -> assertThat(updatedComment.getPost().getId()).isEqualTo(testPost.getId()), - () -> assertThat(updatedComment.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + () -> assertThat(updatedComment.getPost().getId()).isEqualTo(post.getId()), + () -> assertThat(updatedComment.getSiteUserId()).isEqualTo(user1.getId()) ); } @Test - void 다른_사용자의_댓글을_수정하면_예외_응답을_반환한다() { + void 다른_사용자의_댓글을_수정하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment comment = createComment(testPost, 테스트유저_1, "원본 댓글"); + Comment comment = commentFixture.부모_댓글("원본 댓글", post, user1); CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); // when & then assertThatThrownBy(() -> - commentService.updateComment( - 테스트유저_2, - comment.getId(), - request - )) + commentService.updateComment( + user2.getId(), + comment.getId(), + request + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_ACCESS.getMessage()); } @Test - void 삭제된_댓글을_수정하면_예외_응답을_반환한다() { + void 삭제된_댓글을_수정하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment comment = createComment(testPost, 테스트유저_1, null); + Comment comment = commentFixture.부모_댓글(null, post, user1); CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); // when & then assertThatThrownBy(() -> - commentService.updateComment( - 테스트유저_1, - comment.getId(), - request - )) + commentService.updateComment( + user1.getId(), + comment.getId(), + request + )) .isInstanceOf(CustomException.class) .hasMessage(CAN_NOT_UPDATE_DEPRECATED_COMMENT.getMessage()); } @@ -261,22 +330,18 @@ class 댓글_삭제_테스트 { @Transactional void 대댓글이_없는_댓글을_삭제한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment comment = createComment(testPost, 테스트유저_1, "테스트 댓글"); - List comments = testPost.getCommentList(); + Comment comment = commentFixture.부모_댓글("부모 댓글", post, user1); + List comments = post.getCommentList(); int expectedCommentsCount = comments.size() - 1; // when - CommentDeleteResponse response = commentService.deleteCommentById( - 테스트유저_1, - comment.getId() - ); + CommentDeleteResponse response = commentService.deleteCommentById(user1.getId(), comment.getId()); // then assertAll( () -> assertThat(response.id()).isEqualTo(comment.getId()), () -> assertThat(commentRepository.findById(comment.getId())).isEmpty(), - () -> assertThat(testPost.getCommentList()).hasSize(expectedCommentsCount) + () -> assertThat(post.getCommentList()).hasSize(expectedCommentsCount) ); } @@ -284,26 +349,23 @@ class 댓글_삭제_테스트 { @Transactional void 대댓글이_있는_댓글을_삭제하면_내용만_삭제된다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); - Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); - List comments = testPost.getCommentList(); + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment = commentFixture.자식_댓글("자식 댓글", post, user2, parentComment); + List comments = post.getCommentList(); List childComments = parentComment.getCommentList(); // when - CommentDeleteResponse response = commentService.deleteCommentById( - 테스트유저_1, - parentComment.getId() - ); + CommentDeleteResponse response = commentService.deleteCommentById(user1.getId(), parentComment.getId()); // then Comment deletedComment = commentRepository.findById(response.id()).orElseThrow(); assertAll( - () -> assertThat(deletedComment.getContent()).isNull(), + () -> assertThat(deletedComment.getContent()).isEqualTo("부모 댓글"), + () -> assertThat(deletedComment.isDeleted()).isTrue(), () -> assertThat(deletedComment.getCommentList()) .extracting(Comment::getId) .containsExactlyInAnyOrder(childComment.getId()), - () -> assertThat(testPost.getCommentList()).hasSize(comments.size()), + () -> assertThat(post.getCommentList()).hasSize(comments.size()), () -> assertThat(deletedComment.getCommentList()).hasSize(childComments.size()) ); } @@ -312,18 +374,14 @@ class 댓글_삭제_테스트 { @Transactional void 대댓글을_삭제하면_부모댓글이_삭제되지_않는다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); - Comment childComment1 = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글 1"); - Comment childComment2 = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글 2"); + Comment parentComment = commentFixture.부모_댓글("부모 댓글", post, user1); + Comment childComment1 = commentFixture.자식_댓글("자식 댓글1", post, user2, parentComment); + Comment childComment2 = commentFixture.자식_댓글("자식 댓글2", post, user2, parentComment); List childComments = parentComment.getCommentList(); int expectedChildCommentsCount = childComments.size() - 1; // when - CommentDeleteResponse response = commentService.deleteCommentById( - 테스트유저_2, - childComment1.getId() - ); + CommentDeleteResponse response = commentService.deleteCommentById(user2.getId(), childComment1.getId()); // then Comment remainingParentComment = commentRepository.findById(parentComment.getId()).orElseThrow(); @@ -339,70 +397,18 @@ class 댓글_삭제_테스트 { } @Test - @Transactional - void 대댓글을_삭제하고_부모댓글이_삭제된_상태면_부모댓글도_삭제된다() { + void 다른_사용자의_댓글을_삭제하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); - Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); - List comments = testPost.getCommentList(); - int expectedCommentsCount = comments.size() - 2; - parentComment.deprecateComment(); - - // when - CommentDeleteResponse response = commentService.deleteCommentById( - 테스트유저_2, - childComment.getId() - ); - - // then - assertAll( - () -> assertThat(commentRepository.findById(response.id())).isEmpty(), - () -> assertThat(commentRepository.findById(parentComment.getId())).isEmpty(), - () -> assertThat(testPost.getCommentList()).hasSize(expectedCommentsCount) - ); - } - - @Test - void 다른_사용자의_댓글을_삭제하면_예외_응답을_반환한다() { - // given - Post testPost = createPost(자유게시판, 테스트유저_1); - Comment comment = createComment(testPost, 테스트유저_1, "테스트 댓글"); + Comment comment = commentFixture.부모_댓글("부모 댓글", post, user1); // when & then assertThatThrownBy(() -> - commentService.deleteCommentById( - 테스트유저_2, - comment.getId() - )) + commentService.deleteCommentById( + user2.getId(), + comment.getId() + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_ACCESS.getMessage()); } } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "테스트 제목", - "테스트 내용", - false, - 0L, - 0L, - PostCategory.자유 - ); - post.setBoardAndSiteUser(board, siteUser); - return postRepository.save(post); - } - - private Comment createComment(Post post, SiteUser siteUser, String content) { - Comment comment = new Comment(content); - comment.setPostAndSiteUser(post, siteUser); - return commentRepository.save(comment); - } - - private Comment createChildComment(Post post, SiteUser siteUser, Comment parentComment, String content) { - Comment comment = new Comment(content); - comment.setPostAndSiteUser(post, siteUser); - comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); - return commentRepository.save(comment); - } } diff --git a/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java b/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java new file mode 100644 index 000000000..5ddf13888 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java @@ -0,0 +1,50 @@ +package com.example.solidconnection.community.post.fixture; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class PostFixture { + + private final PostFixtureBuilder postFixtureBuilder; + + public Post 게시글( + Board board, + SiteUser siteUser + ) { + return postFixtureBuilder + .title("제목") + .content("내용") + .isQuestion(false) + .likeCount(0L) + .postCategory(PostCategory.자유) + .board(board) + .siteUser(siteUser) + .create(); + } + + public Post 게시글( + String title, + String content, + Boolean isQuestion, + PostCategory postCategory, + Board board, + SiteUser siteUser + ) { + return postFixtureBuilder + .title(title) + .content(content) + .isQuestion(isQuestion) + .likeCount(0L) + .viewCount(0L) + .postCategory(postCategory) + .board(board) + .siteUser(siteUser) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/fixture/PostFixtureBuilder.java b/src/test/java/com/example/solidconnection/community/post/fixture/PostFixtureBuilder.java new file mode 100644 index 000000000..3473d61e2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/fixture/PostFixtureBuilder.java @@ -0,0 +1,77 @@ +package com.example.solidconnection.community.post.fixture; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class PostFixtureBuilder { + + private final PostRepository postRepository; + + private String title; + private String content; + private Boolean isQuestion; + private Long likeCount; + private Long viewCount; + private PostCategory postCategory; + private Board board; + private SiteUser siteUser; + + public PostFixtureBuilder title(String title) { + this.title = title; + return this; + } + + public PostFixtureBuilder content(String content) { + this.content = content; + return this; + } + + public PostFixtureBuilder isQuestion(Boolean isQuestion) { + this.isQuestion = isQuestion; + return this; + } + + public PostFixtureBuilder likeCount(Long likeCount) { + this.likeCount = likeCount; + return this; + } + + public PostFixtureBuilder viewCount(Long viewCount) { + this.viewCount = viewCount; + return this; + } + + public PostFixtureBuilder postCategory(PostCategory postCategory) { + this.postCategory = postCategory; + return this; + } + + public PostFixtureBuilder board(Board board) { + this.board = board; + return this; + } + + public PostFixtureBuilder siteUser(SiteUser siteUser) { + this.siteUser = siteUser; + return this; + } + + public Post create() { + Post post = new Post( + title, + content, + isQuestion, + likeCount, + viewCount, + postCategory); + post.setBoardAndSiteUserId(board.getCode(), siteUser.getId()); + return postRepository.save(post); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/fixture/PostImageFixture.java b/src/test/java/com/example/solidconnection/community/post/fixture/PostImageFixture.java new file mode 100644 index 000000000..565f9fde0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/fixture/PostImageFixture.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.community.post.fixture; + +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostImage; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class PostImageFixture { + + private final PostImageFixtureBuilder postImageFixtureBuilder; + + public PostImage 게시글_이미지(String url, Post post) { + return postImageFixtureBuilder + .url(url) + .post(post) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/fixture/PostImageFixtureBuilder.java b/src/test/java/com/example/solidconnection/community/post/fixture/PostImageFixtureBuilder.java new file mode 100644 index 000000000..e12e0f0a0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/fixture/PostImageFixtureBuilder.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.community.post.fixture; + +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class PostImageFixtureBuilder { + + private final PostImageRepository postImageRepository; + + private String url; + private Post post; + + public PostImageFixtureBuilder url(String url) { + this.url = url; + return this; + } + + public PostImageFixtureBuilder post(Post post) { + this.post = post; + return this; + } + + public PostImage create() { + PostImage postImage = new PostImage(url); + postImage.setPost(post); + return postImageRepository.save(postImage); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java index 328a1dc41..36211c341 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java @@ -1,25 +1,40 @@ package com.example.solidconnection.community.post.service; -import com.example.solidconnection.community.board.domain.Board; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_CATEGORY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.community.board.fixture.BoardFixture; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.domain.PostImage; import com.example.solidconnection.community.post.dto.PostCreateRequest; import com.example.solidconnection.community.post.dto.PostCreateResponse; import com.example.solidconnection.community.post.dto.PostDeleteResponse; import com.example.solidconnection.community.post.dto.PostUpdateRequest; import com.example.solidconnection.community.post.dto.PostUpdateResponse; -import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.community.post.fixture.PostImageFixture; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; -import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.util.RedisUtils; import jakarta.transaction.Transactional; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -28,22 +43,9 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; - +@TestContainerSpringBootTest @DisplayName("게시글 생성/수정/삭제 서비스 테스트") -class PostCommandServiceTest extends BaseIntegrationTest { +class PostCommandServiceTest { @Autowired private PostCommandService postCommandService; @@ -61,7 +63,41 @@ class PostCommandServiceTest extends BaseIntegrationTest { private PostRepository postRepository; @Autowired - private PostImageRepository postImageRepository; + private SiteUserFixture siteUserFixture; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private PostImageFixture postImageFixture; + + private SiteUser user1; + private Post post; + private Post questionPost; + + @BeforeEach + void setUp() { + user1 = siteUserFixture.사용자(1, "test1"); + post = postFixture.게시글( + "제목", + "내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + user1 + ); + questionPost = postFixture.게시글( + "제목", + "내용", + true, + PostCategory.질문, + boardFixture.자유게시판(), + user1 + ); + } @Nested class 게시글_생성_테스트 { @@ -77,21 +113,12 @@ class 게시글_생성_테스트 { .willReturn(List.of(new UploadedFileUrlResponse(expectedImageUrl))); // when - PostCreateResponse response = postCommandService.createPost( - 테스트유저_1, - request, - imageFiles - ); + PostCreateResponse response = postCommandService.createPost(user1.getId(), request, imageFiles); // then Post savedPost = postRepository.findById(response.id()).orElseThrow(); assertAll( () -> assertThat(response.id()).isEqualTo(savedPost.getId()), - () -> assertThat(savedPost.getTitle()).isEqualTo(request.title()), - () -> assertThat(savedPost.getContent()).isEqualTo(request.content()), - () -> assertThat(savedPost.getIsQuestion()).isEqualTo(request.isQuestion()), - () -> assertThat(savedPost.getCategory().name()).isEqualTo(request.postCategory()), - () -> assertThat(savedPost.getBoard().getCode()).isEqualTo(자유게시판.getCode()), () -> assertThat(savedPost.getPostImageList()).hasSize(imageFiles.size()), () -> assertThat(savedPost.getPostImageList()) .extracting(PostImage::getUrl) @@ -100,40 +127,40 @@ class 게시글_생성_테스트 { } @Test - void 전체_카테고리로_생성하면_예외_응답을_반환한다() { + void 전체_카테고리로_생성하면_예외가_발생한다() { // given PostCreateRequest request = createPostCreateRequest(PostCategory.전체.name()); List imageFiles = List.of(); // when & then assertThatThrownBy(() -> - postCommandService.createPost(테스트유저_1, request, imageFiles)) + postCommandService.createPost(user1.getId(), request, imageFiles)) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_CATEGORY.getMessage()); } @Test - void 존재하지_않는_카테고리로_생성하면_예외_응답을_반환한다() { + void 존재하지_않는_카테고리로_생성하면_예외가_발생한다() { // given PostCreateRequest request = createPostCreateRequest("INVALID_CATEGORY"); List imageFiles = List.of(); // when & then assertThatThrownBy(() -> - postCommandService.createPost(테스트유저_1, request, imageFiles)) + postCommandService.createPost(user1.getId(), request, imageFiles)) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_CATEGORY.getMessage()); } @Test - void 이미지를_5개_초과하여_업로드하면_예외_응답을_반환한다() { + void 이미지를_5개_초과하여_업로드하면_예외가_발생한다() { // given PostCreateRequest request = createPostCreateRequest(PostCategory.자유.name()); List imageFiles = createSixImageFiles(); // when & then assertThatThrownBy(() -> - postCommandService.createPost(테스트유저_1, request, imageFiles)) + postCommandService.createPost(user1.getId(), request, imageFiles)) .isInstanceOf(CustomException.class) .hasMessage(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); } @@ -147,8 +174,8 @@ class 게시글_수정_테스트 { void 게시글을_성공적으로_수정한다() { // given String originImageUrl = "origin-image-url"; + postImageFixture.게시글_이미지(originImageUrl, post); String expectedImageUrl = "update-image-url"; - Post testPost = createPost(자유게시판, 테스트유저_1, originImageUrl); PostUpdateRequest request = createPostUpdateRequest(); List imageFiles = List.of(createImageFile()); @@ -157,8 +184,8 @@ class 게시글_수정_테스트 { // when PostUpdateResponse response = postCommandService.updatePost( - 테스트유저_1, - testPost.getId(), + user1.getId(), + post.getId(), request, imageFiles ); @@ -166,9 +193,7 @@ class 게시글_수정_테스트 { // then Post updatedPost = postRepository.findById(response.id()).orElseThrow(); assertAll( - () -> assertThat(updatedPost.getTitle()).isEqualTo(request.title()), - () -> assertThat(updatedPost.getContent()).isEqualTo(request.content()), - () -> assertThat(updatedPost.getCategory().name()).isEqualTo(request.postCategory()), + () -> assertThat(response.id()).isEqualTo(updatedPost.getId()), () -> assertThat(updatedPost.getPostImageList()).hasSize(imageFiles.size()), () -> assertThat(updatedPost.getPostImageList()) .extracting(PostImage::getUrl) @@ -178,58 +203,56 @@ class 게시글_수정_테스트 { } @Test - void 다른_사용자의_게시글을_수정하면_예외_응답을_반환한다() { + void 다른_사용자의_게시글을_수정하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + SiteUser user2 = siteUserFixture.사용자(2, "test2"); PostUpdateRequest request = createPostUpdateRequest(); List imageFiles = List.of(); // when & then assertThatThrownBy(() -> - postCommandService.updatePost( - 테스트유저_2, - testPost.getId(), - request, - imageFiles - )) + postCommandService.updatePost( + user2.getId(), + post.getId(), + request, + imageFiles + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_ACCESS.getMessage()); } @Test - void 질문_게시글을_수정하면_예외_응답을_반환한다() { + void 질문_게시글을_수정하면_예외가_발생한다() { // given - Post testPost = createQuestionPost(자유게시판, 테스트유저_1, "origin-image-url"); PostUpdateRequest request = createPostUpdateRequest(); List imageFiles = List.of(); // when & then assertThatThrownBy(() -> - postCommandService.updatePost( - 테스트유저_1, - testPost.getId(), - request, - imageFiles - )) + postCommandService.updatePost( + user1.getId(), + questionPost.getId(), + request, + imageFiles + )) .isInstanceOf(CustomException.class) .hasMessage(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); } @Test - void 이미지를_5개_초과하여_수정하면_예외_응답을_반환한다() { + void 이미지를_5개_초과하여_수정하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); PostUpdateRequest request = createPostUpdateRequest(); List imageFiles = createSixImageFiles(); // when & then assertThatThrownBy(() -> - postCommandService.updatePost( - 테스트유저_1, - testPost.getId(), - request, - imageFiles - )) + postCommandService.updatePost( + user1.getId(), + post.getId(), + request, + imageFiles + )) .isInstanceOf(CustomException.class) .hasMessage(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); } @@ -242,51 +265,45 @@ class 게시글_삭제_테스트 { void 게시글을_성공적으로_삭제한다() { // given String originImageUrl = "origin-image-url"; - Post testPost = createPost(자유게시판, 테스트유저_1, originImageUrl); - String viewCountKey = redisUtils.getPostViewCountRedisKey(testPost.getId()); + postImageFixture.게시글_이미지(originImageUrl, post); + String viewCountKey = redisUtils.getPostViewCountRedisKey(post.getId()); redisService.increaseViewCount(viewCountKey); // when - PostDeleteResponse response = postCommandService.deletePostById( - 테스트유저_1, - testPost.getId() - ); + PostDeleteResponse response = postCommandService.deletePostById(user1.getId(), post.getId()); // then assertAll( - () -> assertThat(response.id()).isEqualTo(testPost.getId()), - () -> assertThat(postRepository.findById(testPost.getId())).isEmpty(), + () -> assertThat(response.id()).isEqualTo(post.getId()), + () -> assertThat(postRepository.findById(post.getId())).isEmpty(), () -> assertThat(redisService.isKeyExists(viewCountKey)).isFalse() ); then(s3Service).should().deletePostImage(originImageUrl); } @Test - void 다른_사용자의_게시글을_삭제하면_예외_응답을_반환한다() { + void 다른_사용자의_게시글을_삭제하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + SiteUser user2 = siteUserFixture.사용자(2, "test2"); // when & then assertThatThrownBy(() -> - postCommandService.deletePostById( - 테스트유저_2, - testPost.getId() - )) + postCommandService.deletePostById( + user2.getId(), + post.getId() + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_ACCESS.getMessage()); } @Test - void 질문_게시글을_삭제하면_예외_응답을_반환한다() { - // given - Post testPost = createQuestionPost(자유게시판, 테스트유저_1, "origin-image-url"); - + void 질문_게시글을_삭제하면_예외가_발생한다() { // when & then assertThatThrownBy(() -> - postCommandService.deletePostById( - 테스트유저_1, - testPost.getId() - )) + postCommandService.deletePostById( + user1.getId(), + questionPost.getId() + )) .isInstanceOf(CustomException.class) .hasMessage(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); } @@ -294,7 +311,7 @@ class 게시글_삭제_테스트 { private PostCreateRequest createPostCreateRequest(String category) { return new PostCreateRequest( - 자유게시판.getCode(), + boardFixture.자유게시판().getCode(), category, "테스트 제목", "테스트 내용", @@ -322,40 +339,6 @@ private List createSixImageFiles() { ); } - private Post createPost(Board board, SiteUser siteUser, String originImageUrl) { - Post post = new Post( - "원본 제목", - "원본 내용", - false, - 0L, - 0L, - PostCategory.자유 - ); - post.setBoardAndSiteUser(board, siteUser); - Post savedPost = postRepository.save(post); - PostImage postImage = new PostImage(originImageUrl); - postImage.setPost(savedPost); - postImageRepository.save(postImage); - return savedPost; - } - - private Post createQuestionPost(Board board, SiteUser siteUser, String originImageUrl) { - Post post = new Post( - "질문 제목", - "질문 내용", - true, - 0L, - 0L, - PostCategory.질문 - ); - post.setBoardAndSiteUser(board, siteUser); - Post savedPost = postRepository.save(post); - PostImage postImage = new PostImage(originImageUrl); - postImage.setPost(savedPost); - postImageRepository.save(postImage); - return savedPost; - } - private PostUpdateRequest createPostUpdateRequest() { return new PostUpdateRequest( PostCategory.자유.name(), diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java index 23fa6bf50..705ddd6f8 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java @@ -1,28 +1,32 @@ package com.example.solidconnection.community.post.service; -import com.example.solidconnection.community.board.domain.Board; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_POST_LIKE; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_LIKE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.community.board.fixture.BoardFixture; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.dto.PostDislikeResponse; import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.fixture.PostFixture; import com.example.solidconnection.community.post.repository.PostLikeRepository; import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_LIKE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; - +@TestContainerSpringBootTest @DisplayName("게시글 좋아요 서비스 테스트") -class PostLikeServiceTest extends BaseIntegrationTest { +class PostLikeServiceTest { @Autowired private PostLikeService postLikeService; @@ -33,43 +37,63 @@ class PostLikeServiceTest extends BaseIntegrationTest { @Autowired private PostLikeRepository postLikeRepository; + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private PostFixture postFixture; + + private SiteUser user; + private Post post; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + post = postFixture.게시글( + "제목1", + "내용1", + false, + PostCategory.자유, + boardFixture.자유게시판(), + user + ); + } + @Nested class 게시글_좋아요_테스트 { @Test void 게시글을_성공적으로_좋아요한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - long beforeLikeCount = testPost.getLikeCount(); + long beforeLikeCount = post.getLikeCount(); // when - PostLikeResponse response = postLikeService.likePost( - 테스트유저_1, - testPost.getId() - ); + PostLikeResponse response = postLikeService.likePost(user.getId(), post.getId()); // then - Post likedPost = postRepository.findById(testPost.getId()).orElseThrow(); + Post likedPost = postRepository.findById(post.getId()).orElseThrow(); assertAll( () -> assertThat(response.likeCount()).isEqualTo(beforeLikeCount + 1), () -> assertThat(response.isLiked()).isTrue(), () -> assertThat(likedPost.getLikeCount()).isEqualTo(beforeLikeCount + 1), - () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUser(likedPost, 테스트유저_1)).isPresent() + () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUserId(likedPost, user.getId())).isPresent() ); } @Test - void 이미_좋아요한_게시글을_다시_좋아요하면_예외_응답을_반환한다() { + void 이미_좋아요한_게시글을_다시_좋아요하면_예외가_발생한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - postLikeService.likePost(테스트유저_1, testPost.getId()); + postLikeService.likePost(user.getId(), post.getId()); // when & then assertThatThrownBy(() -> - postLikeService.likePost( - 테스트유저_1, - testPost.getId() - )) + postLikeService.likePost( + user.getId(), + post.getId() + )) .isInstanceOf(CustomException.class) .hasMessage(DUPLICATE_POST_LIKE.getMessage()); } @@ -81,52 +105,32 @@ class 게시글_좋아요_취소_테스트 { @Test void 게시글_좋아요를_성공적으로_취소한다() { // given - Post testPost = createPost(자유게시판, 테스트유저_1); - PostLikeResponse beforeResponse = postLikeService.likePost(테스트유저_1, testPost.getId()); + PostLikeResponse beforeResponse = postLikeService.likePost(user.getId(), post.getId()); long beforeLikeCount = beforeResponse.likeCount(); // when - PostDislikeResponse response = postLikeService.dislikePost( - 테스트유저_1, - testPost.getId() - ); + PostDislikeResponse response = postLikeService.dislikePost(user.getId(), post.getId()); // then - Post unlikedPost = postRepository.findById(testPost.getId()).orElseThrow(); + Post unlikedPost = postRepository.findById(post.getId()).orElseThrow(); assertAll( () -> assertThat(response.likeCount()).isEqualTo(beforeLikeCount - 1), () -> assertThat(response.isLiked()).isFalse(), () -> assertThat(unlikedPost.getLikeCount()).isEqualTo(beforeLikeCount - 1), - () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUser(unlikedPost, 테스트유저_1)).isEmpty() + () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUserId(unlikedPost, user.getId())).isEmpty() ); } @Test - void 좋아요하지_않은_게시글을_좋아요_취소하면_예외_응답을_반환한다() { - // given - Post testPost = createPost(자유게시판, 테스트유저_1); - + void 좋아요하지_않은_게시글을_좋아요_취소하면_예외가_발생한다() { // when & then assertThatThrownBy(() -> - postLikeService.dislikePost( - 테스트유저_1, - testPost.getId() - )) + postLikeService.dislikePost( + user.getId(), + post.getId() + )) .isInstanceOf(CustomException.class) .hasMessage(INVALID_POST_LIKE.getMessage()); } } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "테스트 제목", - "테스트 내용", - false, - 0L, - 0L, - PostCategory.자유 - ); - post.setBoardAndSiteUser(board, siteUser); - return postRepository.save(post); - } } diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java index fc7926698..f5e1bb45b 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java @@ -1,34 +1,32 @@ package com.example.solidconnection.community.post.service; -import com.example.solidconnection.community.board.domain.Board; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.community.board.domain.BoardCode; +import com.example.solidconnection.community.board.fixture.BoardFixture; import com.example.solidconnection.community.comment.domain.Comment; -import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; -import com.example.solidconnection.community.post.dto.PostListResponse; -import com.example.solidconnection.community.comment.repository.CommentRepository; -import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.comment.fixture.CommentFixture; import com.example.solidconnection.community.post.domain.Post; -import com.example.solidconnection.community.post.dto.PostFindPostImageResponse; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.dto.PostFindResponse; -import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.community.post.repository.PostImageRepository; -import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.community.post.fixture.PostImageFixture; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.BoardCode; -import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.util.RedisUtils; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import java.time.ZonedDateTime; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - +@TestContainerSpringBootTest @DisplayName("게시글 조회 서비스 테스트") -class PostQueryServiceTest extends BaseIntegrationTest { +class PostQueryServiceTest { @Autowired private PostQueryService postQueryService; @@ -40,23 +38,61 @@ class PostQueryServiceTest extends BaseIntegrationTest { private RedisUtils redisUtils; @Autowired - private PostRepository postRepository; + private SiteUserFixture siteUserFixture; @Autowired - private CommentRepository commentRepository; + private BoardFixture boardFixture; @Autowired - private PostImageRepository postImageRepository; + private PostFixture postFixture; + + @Autowired + private PostImageFixture postImageFixture; + + @Autowired + private CommentFixture commentFixture; + + private SiteUser user; + private Post post1; + private Post post2; + private Post post3; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + post1 = postFixture.게시글( + "제목1", + "내용1", + false, + PostCategory.자유, + boardFixture.자유게시판(), + user + ); + post2 = postFixture.게시글( + "제목2", + "내용2", + false, + PostCategory.자유, + boardFixture.미주권(), + user + ); + post3 = postFixture.게시글( + "제목3", + "내용3", + true, + PostCategory.질문, + boardFixture.자유게시판(), + user + ); + } @Test void 게시판_코드와_카테고리로_게시글_목록을_조회한다() { // given - List posts = List.of( - 미주권_자유게시글, 아시아권_자유게시글, 유럽권_자유게시글, 자유게시판_자유게시글, - 미주권_질문게시글, 아시아권_질문게시글, 유럽권_질문게시글, 자유게시판_질문게시글 - ); + List posts = List.of(post1, post2, post3); List expectedPosts = posts.stream() - .filter(post -> post.getCategory().equals(PostCategory.자유) && post.getBoard().getCode().equals(BoardCode.FREE.name())) + .filter(post -> post.getCategory().equals(PostCategory.자유) + && post.getBoardCode().equals(BoardCode.FREE.name())) .toList(); List expectedResponses = PostListResponse.from(expectedPosts); @@ -76,12 +112,9 @@ class PostQueryServiceTest extends BaseIntegrationTest { @Test void 전체_카테고리로_조회시_해당_게시판의_모든_게시글을_조회한다() { // given - List posts = List.of( - 미주권_자유게시글, 아시아권_자유게시글, 유럽권_자유게시글, 자유게시판_자유게시글, - 미주권_질문게시글, 아시아권_질문게시글, 유럽권_질문게시글, 자유게시판_질문게시글 - ); + List posts = List.of(post1, post2, post3); List expectedPosts = posts.stream() - .filter(post -> post.getBoard().getCode().equals(BoardCode.FREE.name())) + .filter(post -> post.getBoardCode().equals(BoardCode.FREE.name())) .toList(); List expectedResponses = PostListResponse.from(expectedPosts); @@ -103,77 +136,74 @@ class PostQueryServiceTest extends BaseIntegrationTest { // given String expectedImageUrl = "test-image-url"; List imageUrls = List.of(expectedImageUrl); - Post testPost = createPost(자유게시판, 테스트유저_1, expectedImageUrl); - List comments = createComments(testPost, 테스트유저_1, List.of("첫번째 댓글", "두번째 댓글")); + Post post = postFixture.게시글( + "제목", + "내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + user + ); + postImageFixture.게시글_이미지(expectedImageUrl, post); + Comment comment1 = commentFixture.부모_댓글("댓글1", post, user); + Comment comment2 = commentFixture.부모_댓글("댓글2", post, user); + List comments = List.of(comment1, comment2); - String validateKey = redisUtils.getValidatePostViewCountRedisKey(테스트유저_1.getId(), testPost.getId()); - String viewCountKey = redisUtils.getPostViewCountRedisKey(testPost.getId()); + String validateKey = redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId()); + String viewCountKey = redisUtils.getPostViewCountRedisKey(post.getId()); // when - PostFindResponse response = postQueryService.findPostById( - 테스트유저_1, - testPost.getId() - ); + PostFindResponse response = postQueryService.findPostById(user.getId(), post.getId()); // then assertAll( - () -> assertThat(response.id()).isEqualTo(testPost.getId()), - () -> assertThat(response.title()).isEqualTo(testPost.getTitle()), - () -> assertThat(response.content()).isEqualTo(testPost.getContent()), - () -> assertThat(response.isQuestion()).isEqualTo(testPost.getIsQuestion()), - () -> assertThat(response.likeCount()).isEqualTo(testPost.getLikeCount()), - () -> assertThat(response.viewCount()).isEqualTo(testPost.getViewCount()), - () -> assertThat(response.postCategory()).isEqualTo(String.valueOf(testPost.getCategory())), - - () -> assertThat(response.postFindBoardResponse().code()).isEqualTo(자유게시판.getCode()), - () -> assertThat(response.postFindBoardResponse().koreanName()).isEqualTo(자유게시판.getKoreanName()), - - () -> assertThat(response.postFindSiteUserResponse().id()).isEqualTo(테스트유저_1.getId()), - () -> assertThat(response.postFindSiteUserResponse().nickname()).isEqualTo(테스트유저_1.getNickname()), - () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()).isEqualTo(테스트유저_1.getProfileImageUrl()), - - () -> assertThat(response.postFindPostImageResponses()) - .hasSize(imageUrls.size()) - .extracting(PostFindPostImageResponse::url) - .containsExactlyElementsOf(imageUrls), - - () -> assertThat(response.postFindCommentResponses()) - .hasSize(comments.size()) - .extracting(PostFindCommentResponse::content) - .containsExactlyElementsOf(comments.stream().map(Comment::getContent).toList()), - - () -> assertThat(response.isOwner()).isTrue(), - () -> assertThat(response.isLiked()).isFalse(), - + () -> assertThat(response.id()).isEqualTo(post.getId()), + () -> assertThat(response.postFindBoardResponse().code()).isEqualTo(boardFixture.자유게시판().getCode()), + () -> assertThat(response.postFindSiteUserResponse().id()).isEqualTo(user.getId()), + () -> assertThat(response.postFindPostImageResponses()).hasSize(imageUrls.size()), + () -> assertThat(response.postFindCommentResponses()).hasSize(comments.size()), () -> assertThat(redisService.isKeyExists(viewCountKey)).isTrue(), () -> assertThat(redisService.isKeyExists(validateKey)).isTrue() ); } - private Post createPost(Board board, SiteUser siteUser, String originImageUrl) { - Post post = new Post( - "원본 제목", - "원본 내용", - false, - 0L, - 0L, - PostCategory.자유 + @Test + void 게시글_목록_조회시_첫번째_이미지를_썸네일로_반환한다() { + // given + String firstImageUrl = "first-thumbnail-url"; + String secondImageUrl = "second-thumbnail-url"; + postImageFixture.게시글_이미지(firstImageUrl, post1); + postImageFixture.게시글_이미지(secondImageUrl, post1); + + // when + List actualResponses = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.전체.name() ); - post.setBoardAndSiteUser(board, siteUser); - Post savedPost = postRepository.save(post); - PostImage postImage = new PostImage(originImageUrl); - postImage.setPost(savedPost); - postImageRepository.save(postImage); - return savedPost; + + // then + PostListResponse postResponse = actualResponses.stream() + .filter(p -> p.id().equals(post1.getId())) + .findFirst() + .orElseThrow(); + + assertThat(postResponse.postThumbnailUrl()).isEqualTo(firstImageUrl); } - private List createComments(Post post, SiteUser siteUser, List contents) { - return contents.stream() - .map(content -> { - Comment comment = new Comment(content); - comment.setPostAndSiteUser(post, siteUser); - return commentRepository.save(comment); - }) - .toList(); + @Test + void 게시글에_이미지가_없다면_썸네일로_null을_반환한다() { + // when + List actualResponses = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.전체.name() + ); + + // then + PostListResponse postResponse = actualResponses.stream() + .filter(p -> p.id().equals(post3.getId())) + .findFirst() + .orElseThrow(); + + assertThat(postResponse.postThumbnailUrl()).isNull(); } } diff --git a/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java index fdcf0ec8d..7a9e67b99 100644 --- a/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java @@ -1,29 +1,25 @@ package com.example.solidconnection.concurrency; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.example.solidconnection.community.board.domain.Board; -import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.board.fixture.BoardFixture; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.fixture.PostFixture; import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.community.post.service.PostLikeService; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; - import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; - -import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; -import static org.junit.jupiter.api.Assertions.assertEquals; +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; @TestContainerSpringBootTest @DisplayName("게시글 좋아요 동시성 테스트") @@ -31,60 +27,38 @@ class PostLikeCountConcurrencyTest { @Autowired private PostLikeService postLikeService; + @Autowired private PostRepository postRepository; + @Autowired - private BoardRepository boardRepository; + private SiteUserFixture siteUserFixture; + @Autowired - private SiteUserRepository siteUserRepository; + private BoardFixture boardFixture; + + @Autowired + private PostFixture postFixture; - @Value("${view.count.scheduling.delay}") - private int SCHEDULING_DELAY_MS; private int THREAD_NUMS = 1000; private int THREAD_POOL_SIZE = 200; private int TIMEOUT_SECONDS = 10; private Post post; private Board board; - private SiteUser siteUser; + private SiteUser user; @BeforeEach void setUp() { - board = createBoard(); - boardRepository.save(board); - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - post = createPost(board, siteUser); - postRepository.save(post); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( + board = boardFixture.자유게시판(); + user = siteUserFixture.사용자(); + post = postFixture.게시글( "title", "content", false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - - return post; + PostCategory.자유, + board, + user); } @Test @@ -96,18 +70,17 @@ private Post createPost(Board board, SiteUser siteUser) { Long likeCount = postRepository.getById(post.getId()).getLikeCount(); for (int i = 0; i < THREAD_NUMS; i++) { - String email = "email" + i; - SiteUser tmpSiteUser = siteUserRepository.save(createSiteUserByEmail(email)); + String nickname = "nickname" + i; + SiteUser tmpSiteUser = siteUserFixture.사용자(i, nickname); executorService.submit(() -> { try { - postLikeService.likePost(tmpSiteUser, post.getId()); - postLikeService.dislikePost(tmpSiteUser, post.getId()); + postLikeService.likePost(tmpSiteUser.getId(), post.getId()); + postLikeService.dislikePost(tmpSiteUser.getId(), post.getId()); } finally { doneSignal.countDown(); } }); } - doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); executorService.shutdown(); boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); diff --git a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java index beeb8b046..4396e697b 100644 --- a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java @@ -1,76 +1,67 @@ package com.example.solidconnection.concurrency; +import static com.example.solidconnection.community.post.service.RedisConstants.VALIDATE_VIEW_COUNT_TTL; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.example.solidconnection.community.board.domain.Board; import com.example.solidconnection.community.board.repository.BoardRepository; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.community.post.service.RedisService; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; import com.example.solidconnection.util.RedisUtils; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -import static com.example.solidconnection.type.RedisConstants.*; -import static org.junit.jupiter.api.Assertions.assertEquals; - @TestContainerSpringBootTest @DisplayName("게시글 조회수 동시성 테스트") -public class PostViewCountConcurrencyTest { +class PostViewCountConcurrencyTest { @Autowired private RedisService redisService; + @Autowired private PostRepository postRepository; + @Autowired private BoardRepository boardRepository; - @Autowired - private SiteUserRepository siteUserRepository; + @Autowired private RedisUtils redisUtils; + @Autowired + private SiteUserFixture siteUserFixture; + @Value("${view.count.scheduling.delay}") private int SCHEDULING_DELAY_MS; + private int THREAD_NUMS = 1000; private int THREAD_POOL_SIZE = 200; private int TIMEOUT_SECONDS = 10; private Post post; private Board board; - private SiteUser siteUser; + private SiteUser user; @BeforeEach - public void setUp() { + void setUp() { board = createBoard(); boardRepository.save(board); - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - post = createPost(board, siteUser); + user = siteUserFixture.사용자(); + post = createPost(board, user); postRepository.save(post); } - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - } - private Board createBoard() { return new Board( "FREE", "자유게시판"); @@ -85,15 +76,15 @@ private Post createPost(Board board, SiteUser siteUser) { 0L, PostCategory.valueOf("자유") ); - post.setBoardAndSiteUser(board, siteUser); + post.setBoardAndSiteUserId(board.getCode(), siteUser.getId()); return post; } @Test - public void 게시글을_조회할_때_조회수_동시성_문제를_해결한다() throws InterruptedException { + void 게시글을_조회할_때_조회수_동시성_문제를_해결한다() throws InterruptedException { - redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); @@ -121,9 +112,9 @@ private Post createPost(Board board, SiteUser siteUser) { } @Test - public void 게시글을_조회할_때_조회수_조작_문제를_해결한다() throws InterruptedException { + void 게시글을_조회할_때_조회수_조작_문제를_해결한다() throws InterruptedException { - redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); @@ -131,7 +122,7 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); if (isFirstTime) { redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); } @@ -144,7 +135,7 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); if (isFirstTime) { redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); } diff --git a/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java index 4800a0153..9e86965af 100644 --- a/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java @@ -1,17 +1,8 @@ package com.example.solidconnection.concurrency; import com.example.solidconnection.application.service.ApplicationQueryService; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -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.data.redis.core.RedisTemplate; - import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -19,35 +10,33 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +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.data.redis.core.RedisTemplate; @TestContainerSpringBootTest @DisplayName("ThunderingHerd 테스트") -public class ThunderingHerdTest { +class ThunderingHerdTest { + @Autowired private ApplicationQueryService applicationQueryService; + @Autowired private RedisTemplate redisTemplate; + @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; + private int THREAD_NUMS = 1000; private int THREAD_POOL_SIZE = 200; private int TIMEOUT_SECONDS = 10; - private SiteUser siteUser; + private long siteUserId; @BeforeEach public void setUp() { - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); + siteUserId = siteUserFixture.사용자().getId(); } @Test @@ -63,9 +52,9 @@ private SiteUser createSiteUser() { executorService.submit(() -> { try { List tasks = Arrays.asList( - () -> applicationQueryService.getApplicants(siteUser, "", ""), - () -> applicationQueryService.getApplicants(siteUser, "ASIA", ""), - () -> applicationQueryService.getApplicants(siteUser, "", "추오") + () -> applicationQueryService.getApplicants(siteUserId, "", ""), + () -> applicationQueryService.getApplicants(siteUserId, "ASIA", ""), + () -> applicationQueryService.getApplicants(siteUserId, "", "추오") ); Collections.shuffle(tasks); tasks.forEach(Runnable::run); diff --git a/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java b/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java deleted file mode 100644 index b517681aa..000000000 --- a/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.example.solidconnection.custom.resolver; - - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; -import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.MethodParameter; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; - -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; - -@TestContainerSpringBootTest -@DisplayName("인증된 사용자 argument resolver 테스트") -class AuthorizedUserResolverTest { - - @Autowired - private AuthorizedUserResolver authorizedUserResolver; - - @Autowired - private SiteUserRepository siteUserRepository; - - @BeforeEach - void setUp() { - SecurityContextHolder.clearContext(); - } - - @Test - void security_context_에_저장된_인증된_사용자를_반환한다() { - // given - SiteUser siteUser = createAndSaveSiteUser(); - Authentication authentication = createAuthenticationWithUser(siteUser); - SecurityContextHolder.getContext().setAuthentication(authentication); - - MethodParameter parameter = mock(MethodParameter.class); - AuthorizedUser authorizedUser = mock(AuthorizedUser.class); - given(parameter.getParameterAnnotation(AuthorizedUser.class)).willReturn(authorizedUser); - given(authorizedUser.required()).willReturn(false); - - // when - SiteUser resolveSiteUser = (SiteUser) authorizedUserResolver.resolveArgument(parameter, null, null, null); - - // then - assertThat(resolveSiteUser).isEqualTo(siteUser); - } - - @Nested - class security_context_에_저장된_사용자가_없는_경우 { - - @Test - void required_가_true_이면_예외_응답을_반환한다() { - // given - MethodParameter parameter = mock(MethodParameter.class); - AuthorizedUser authorizedUser = mock(AuthorizedUser.class); - given(parameter.getParameterAnnotation(AuthorizedUser.class)).willReturn(authorizedUser); - given(authorizedUser.required()).willReturn(true); - - // when, then - assertThatCode(() -> authorizedUserResolver.resolveArgument(parameter, null, null, null)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); - } - - @Test - void required_가_false_이면_null_을_반환한다() { - // given - MethodParameter parameter = mock(MethodParameter.class); - AuthorizedUser authorizedUser = mock(AuthorizedUser.class); - given(parameter.getParameterAnnotation(AuthorizedUser.class)).willReturn(authorizedUser); - given(authorizedUser.required()).willReturn(false); - - // when, then - assertThat( - authorizedUserResolver.resolveArgument(parameter, null, null, null) - ).isNull(); - } - } - - private SiteUser createAndSaveSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } - - private SiteUserAuthentication createAuthenticationWithUser(SiteUser siteUser) { - SiteUserDetails userDetails = new SiteUserDetails(siteUser); - return new SiteUserAuthentication("token", userDetails); - } -} diff --git a/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java b/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java deleted file mode 100644 index a0393dbc7..000000000 --- a/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.example.solidconnection.custom.resolver; - -import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; -import com.example.solidconnection.support.TestContainerSpringBootTest; -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.security.core.context.SecurityContextHolder; - -import static org.assertj.core.api.Assertions.assertThat; - -@TestContainerSpringBootTest -@DisplayName("만료된 토큰 argument resolver 테스트") -class ExpiredTokenResolverTest { - - @BeforeEach - void setUp() { - SecurityContextHolder.clearContext(); - } - - @Autowired - private ExpiredTokenResolver expiredTokenResolver; - - @Test - void security_context_에_저장된_만료시간을_검증하지_않는_토큰을_반환한다() throws Exception { - // given - ExpiredTokenAuthentication authentication = new ExpiredTokenAuthentication("token"); - SecurityContextHolder.getContext().setAuthentication(authentication); - - // when - ExpiredTokenAuthentication expiredTokenAuthentication = (ExpiredTokenAuthentication) expiredTokenResolver.resolveArgument(null, null, null, null); - - // then - assertThat(expiredTokenAuthentication.getToken()).isEqualTo("token"); - } - - @Test - void security_context_에_저장된_만료시간을_검증하지_않는_토큰이_없으면_null_을_반환한다() throws Exception { - // when, then - assertThat(expiredTokenResolver.resolveArgument(null, null, null, null)).isNull(); - } -} diff --git a/src/test/java/com/example/solidconnection/custom/security/aspect/AdminAuthorizationAspectTest.java b/src/test/java/com/example/solidconnection/custom/security/aspect/AdminAuthorizationAspectTest.java deleted file mode 100644 index 0104c9ccd..000000000 --- a/src/test/java/com/example/solidconnection/custom/security/aspect/AdminAuthorizationAspectTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.example.solidconnection.custom.security.aspect; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.security.annotation.RequireAdminAccess; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.stereotype.Component; - -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; - -@TestContainerSpringBootTest -@DisplayName("어드민 권한 검사 Aspect 테스트") -class AdminAuthorizationAspectTest { - - @Autowired - private TestService testService; - - @Test - void 어드민_사용자는_어드민_전용_메소드에_접근할_수_있다() { - // given - SiteUser adminUser = createSiteUser(Role.ADMIN); - - // when - boolean response = testService.adminOnlyMethod(adminUser); - - // then - assertThat(response).isTrue(); - } - - @Test - void 일반_사용자가_어드민_전용_메소드에_접근하면_예외_응답을_반환한다() { - // given - SiteUser mentorUser = createSiteUser(Role.MENTOR); - - // when & then - assertThatCode(() -> testService.adminOnlyMethod(mentorUser)) - .isInstanceOf(CustomException.class) - .hasMessage(ACCESS_DENIED.getMessage()); - } - - @Test - void 어드민_어노테이션이_없는_메소드는_모두_접근_가능하다() { - // given - SiteUser menteeUser = createSiteUser(Role.MENTEE); - SiteUser adminUser = createSiteUser(Role.ADMIN); - - // when - boolean menteeResponse = testService.publicMethod(menteeUser); - boolean adminResponse = testService.publicMethod(adminUser); - - // then - assertThat(menteeResponse).isTrue(); - assertThat(adminResponse).isTrue(); - } - - private SiteUser createSiteUser(Role role) { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - role - ); - } - - @TestConfiguration - static class TestConfig { - - @Bean - public TestService testService() { - return new TestService(); - } - } - - @Component - static class TestService { - - @RequireAdminAccess - public boolean adminOnlyMethod(SiteUser siteUser) { - return true; - } - - public boolean publicMethod(SiteUser siteUser) { - return true; - } - } -} diff --git a/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java b/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java deleted file mode 100644 index 9ef78d0c7..000000000 --- a/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.solidconnection.custom.security.authentication; - -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.Date; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@DisplayName("만료된 토큰 인증 정보 테스트") -class ExpiredTokenAuthenticationTest { - - @Test - void 인증_정보에_저장된_토큰을_반환한다() { - // given - String token = "token123"; - ExpiredTokenAuthentication auth = new ExpiredTokenAuthentication(token); - - // when - String result = auth.getToken(); - - // then - assertThat(result).isEqualTo(token); - } - - @Test - void 인증_정보에_저장된_토큰의_subject_를_반환한다() { - // given - String subject = "subject321"; - String token = createToken(subject); - ExpiredTokenAuthentication auth = new ExpiredTokenAuthentication(token, subject); - - // when - String result = auth.getSubject(); - - // then - assertThat(result).isEqualTo(subject); - } - - @Test - void 항상_isAuthenticated_는_false_를_반환한다() { - // given - ExpiredTokenAuthentication auth1 = new ExpiredTokenAuthentication("token"); - ExpiredTokenAuthentication auth2 = new ExpiredTokenAuthentication("token", "subject"); - - // when & then - assertAll( - () -> assertThat(auth1.isAuthenticated()).isFalse(), - () -> assertThat(auth2.isAuthenticated()).isFalse() - ); - } - - private String createToken(String subject) { - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, "secret") - .compact(); - } -} diff --git a/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java b/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java deleted file mode 100644 index 6285727cb..000000000 --- a/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.solidconnection.custom.security.authentication; - -import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class SiteUserAuthenticationTest { - - @Test - void 인증_정보에_저장된_토큰을_반환한다() { - // given - String token = "token"; - SiteUserAuthentication authentication = new SiteUserAuthentication(token); - - // when - String result = authentication.getToken(); - - // then - assertThat(result).isEqualTo(token); - } - - @Test - void 인증_정보에_저장된_사용자를_반환한다() { - // given - SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); - SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); - - // when & then - SiteUserDetails actual = (SiteUserDetails) authentication.getPrincipal(); - - // then - assertThat(actual) - .extracting("siteUser") - .extracting("id") - .isEqualTo(userDetails.getSiteUser().getId()); - } - - @Test - void 인증_전에_생성되면_isAuthenticated_는_false_를_반환한다() { - // given - SiteUserAuthentication authentication = new SiteUserAuthentication("token"); - - // when & then - assertThat(authentication.isAuthenticated()).isFalse(); - } - - @Test - void 인증_후에_생성되면_isAuthenticated_는_true_를_반환한다() { - // given - SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); - SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); - - // when & then - assertThat(authentication.isAuthenticated()).isTrue(); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - } -} diff --git a/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java b/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java deleted file mode 100644 index ad6053359..000000000 --- a/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.example.solidconnection.custom.security.provider; - -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.Authentication; - -import java.net.PasswordAuthentication; -import java.util.Date; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.*; - -@TestContainerSpringBootTest -@DisplayName("만료된 토큰 provider 테스트") -class ExpiredTokenAuthenticationProviderTest { - - @Autowired - private ExpiredTokenAuthenticationProvider expiredTokenAuthenticationProvider; - - @Autowired - private JwtProperties jwtProperties; - - @Test - void 처리할_수_있는_타입인지를_반환한다() { - // given - Class supportedType = ExpiredTokenAuthentication.class; - Class notSupportedType = PasswordAuthentication.class; - - // when & then - assertAll( - () -> assertTrue(expiredTokenAuthenticationProvider.supports(supportedType)), - () -> assertFalse(expiredTokenAuthenticationProvider.supports(notSupportedType)) - ); - } - - @Test - void 만료된_토큰의_인증_정보를_반환한다() { - // given - String expiredToken = createExpiredToken(); - ExpiredTokenAuthentication ExpiredTokenAuthentication = new ExpiredTokenAuthentication(expiredToken); - - // when - Authentication result = expiredTokenAuthenticationProvider.authenticate(ExpiredTokenAuthentication); - - // then - assertAll( - () -> assertThat(result).isInstanceOf(ExpiredTokenAuthentication.class), - () -> assertThat(result.isAuthenticated()).isFalse() - ); - } - - @Test - void 유효하지_않은_토큰이면_예외_응답을_반환한다() { - // given - ExpiredTokenAuthentication ExpiredTokenAuthentication = new ExpiredTokenAuthentication("invalid token"); - - // when & then - assertThatCode(() -> expiredTokenAuthenticationProvider.authenticate(ExpiredTokenAuthentication)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(INVALID_TOKEN.getMessage()); - } - - private String createExpiredToken() { - return Jwts.builder() - .setSubject("1") - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() - 1000)) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) - .compact(); - } -} diff --git a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java index ca3c64c7a..aa7297083 100644 --- a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java @@ -1,5 +1,12 @@ package com.example.solidconnection.database; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Objects; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,14 +16,6 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.jdbc.core.JdbcTemplate; -import java.sql.DatabaseMetaData; -import java.sql.SQLException; -import java.util.Objects; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - @Disabled @AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2, replace = AutoConfigureTestDatabase.Replace.ANY) @DataJpaTest diff --git a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java index 527ae7e07..487811392 100644 --- a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.database; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -7,8 +9,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; -import static org.assertj.core.api.Assertions.assertThat; - @Disabled @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class RedisConnectionTest { diff --git a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java deleted file mode 100644 index 43af92ea5..000000000 --- a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.solidconnection.e2e; - -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; - -public class DynamicFixture { // todo: test fixture 개선 작업 이후, 이 클래스의 사용이 대체되면 삭제 필요 - - public static SiteUser createSiteUserByEmail(String email) { - return new SiteUser( - email, - "nickname", - "profileImage", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - } -} diff --git a/src/test/java/com/example/solidconnection/location/country/fixture/CountryFixture.java b/src/test/java/com/example/solidconnection/location/country/fixture/CountryFixture.java new file mode 100644 index 000000000..cf3e2d6b1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/country/fixture/CountryFixture.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.location.country.fixture; + +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.region.fixture.RegionFixture; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class CountryFixture { + + private final RegionFixture regionFixture; + private final CountryFixtureBuilder countryFixtureBuilder; + + public Country 미국() { + return countryFixtureBuilder.country() + .code("US") + .koreanName("미국") + .region(regionFixture.영미권()) + .findOrCreate(); + } + + public Country 캐나다() { + return countryFixtureBuilder.country() + .code("CA") + .koreanName("캐나다") + .region(regionFixture.영미권()) + .findOrCreate(); + } + + public Country 덴마크() { + return countryFixtureBuilder.country() + .code("DK") + .koreanName("덴마크") + .region(regionFixture.유럽()) + .findOrCreate(); + } + + public Country 오스트리아() { + return countryFixtureBuilder.country() + .code("AT") + .koreanName("오스트리아") + .region(regionFixture.유럽()) + .findOrCreate(); + } + + public Country 일본() { + return countryFixtureBuilder.country() + .code("JP") + .koreanName("일본") + .region(regionFixture.아시아()) + .findOrCreate(); + } +} diff --git a/src/test/java/com/example/solidconnection/location/country/fixture/CountryFixtureBuilder.java b/src/test/java/com/example/solidconnection/location/country/fixture/CountryFixtureBuilder.java new file mode 100644 index 000000000..2f6ce7011 --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/country/fixture/CountryFixtureBuilder.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.location.country.fixture; + +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.country.repository.CountryRepositoryForTest; +import com.example.solidconnection.location.region.domain.Region; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class CountryFixtureBuilder { + + private final CountryRepositoryForTest countryRepositoryForTest; + + private String code; + private String koreanName; + private Region region; + + public CountryFixtureBuilder country() { + return new CountryFixtureBuilder(countryRepositoryForTest); + } + + public CountryFixtureBuilder code(String code) { + this.code = code; + return this; + } + + public CountryFixtureBuilder koreanName(String koreanName) { + this.koreanName = koreanName; + return this; + } + + public CountryFixtureBuilder region(Region region) { + this.region = region; + return this; + } + + public Country findOrCreate() { + return countryRepositoryForTest.findByCode(code) + .orElseGet(() -> countryRepositoryForTest.save(new Country(code, koreanName, region.getCode()))); + } +} diff --git a/src/test/java/com/example/solidconnection/location/country/repository/CountryRepositoryForTest.java b/src/test/java/com/example/solidconnection/location/country/repository/CountryRepositoryForTest.java new file mode 100644 index 000000000..6e9e1c52f --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/country/repository/CountryRepositoryForTest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.location.country.repository; + +import com.example.solidconnection.location.country.domain.Country; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CountryRepositoryForTest extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/test/java/com/example/solidconnection/location/country/repository/InterestedCountryRepositoryTest.java b/src/test/java/com/example/solidconnection/location/country/repository/InterestedCountryRepositoryTest.java new file mode 100644 index 000000000..4d27b95f2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/country/repository/InterestedCountryRepositoryTest.java @@ -0,0 +1,86 @@ +package com.example.solidconnection.location.country.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.country.domain.InterestedCountry; +import com.example.solidconnection.location.country.fixture.CountryFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +@TestContainerSpringBootTest +public class InterestedCountryRepositoryTest { + + @Autowired + private InterestedCountryRepository interestedCountryRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private CountryFixture countryFixture; + + @Nested + class 사용자와_나라는_복합_유니크_제약_조건을_가진다 { + + @Test + void 같은_사용자가_같은_나라에_관심_표시를_하면_예외가_발생한다() { + // given + SiteUser user = siteUserFixture.사용자(); + Country country = countryFixture.미국(); + + InterestedCountry firstInterest = new InterestedCountry(user, country); + interestedCountryRepository.save(firstInterest); + + InterestedCountry secondInterest = new InterestedCountry(user, country); + + // when & then + assertThatCode(() -> interestedCountryRepository.save(secondInterest)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 다른_사용자가_같은_나라에_관심_표시를_하면_정상_저장된다() { + // given + SiteUser user1 = siteUserFixture.사용자(1, "user1"); + SiteUser user2 = siteUserFixture.사용자(2, "user2"); + Country country = countryFixture.미국(); + + InterestedCountry firstInterest = new InterestedCountry(user1, country); + interestedCountryRepository.save(firstInterest); + + InterestedCountry secondInterest = new InterestedCountry(user2, country); + + // when & then + assertThatCode(() -> { + InterestedCountry saved = interestedCountryRepository.save(secondInterest); + assertThat(saved.getId()).isNotNull(); + }).doesNotThrowAnyException(); + } + + @Test + void 같은_사용자가_다른_나라에_관심_표시를_하면_정상_저장된다() { + // given + SiteUser user = siteUserFixture.사용자(); + Country country1 = countryFixture.미국(); + Country country2 = countryFixture.일본(); + + InterestedCountry firstInterest = new InterestedCountry(user, country1); + interestedCountryRepository.save(firstInterest); + + InterestedCountry secondInterest = new InterestedCountry(user, country2); + + // when & then + assertThatCode(() -> { + InterestedCountry saved = interestedCountryRepository.save(secondInterest); + assertThat(saved.getId()).isNotNull(); + }).doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/location/region/fixture/RegionFixture.java b/src/test/java/com/example/solidconnection/location/region/fixture/RegionFixture.java new file mode 100644 index 000000000..2cf13437e --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/region/fixture/RegionFixture.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.location.region.fixture; + +import com.example.solidconnection.location.region.domain.Region; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class RegionFixture { + + private final RegionFixtureBuilder regionFixtureBuilder; + + public Region 영미권() { + return regionFixtureBuilder.region() + .code("AMERICAS") + .koreanName("영미권") + .findOrCreate(); + } + + public Region 유럽() { + return regionFixtureBuilder.region() + .code("EUROPE") + .koreanName("유럽") + .findOrCreate(); + } + + public Region 아시아() { + return regionFixtureBuilder.region() + .code("ASIA") + .koreanName("아시아") + .findOrCreate(); + } +} diff --git a/src/test/java/com/example/solidconnection/location/region/fixture/RegionFixtureBuilder.java b/src/test/java/com/example/solidconnection/location/region/fixture/RegionFixtureBuilder.java new file mode 100644 index 000000000..968665e22 --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/region/fixture/RegionFixtureBuilder.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.location.region.fixture; + +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.repository.RegionRepositoryForTest; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class RegionFixtureBuilder { + + private final RegionRepositoryForTest regionRepositoryForTest; + + private String code; + private String koreanName; + + public RegionFixtureBuilder region() { + return new RegionFixtureBuilder(regionRepositoryForTest); + } + + public RegionFixtureBuilder code(String code) { + this.code = code; + return this; + } + + public RegionFixtureBuilder koreanName(String koreanName) { + this.koreanName = koreanName; + return this; + } + + public Region findOrCreate() { + return regionRepositoryForTest.findByCode(code) + .orElseGet(() -> regionRepositoryForTest.save(new Region(code, koreanName))); + } +} diff --git a/src/test/java/com/example/solidconnection/location/region/repository/InterestedRegionRepositoryTest.java b/src/test/java/com/example/solidconnection/location/region/repository/InterestedRegionRepositoryTest.java new file mode 100644 index 000000000..3fc993b93 --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/region/repository/InterestedRegionRepositoryTest.java @@ -0,0 +1,86 @@ +package com.example.solidconnection.location.region.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.example.solidconnection.location.region.domain.InterestedRegion; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.fixture.RegionFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +@TestContainerSpringBootTest +public class InterestedRegionRepositoryTest { + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private RegionFixture regionFixture; + + @Nested + class 사용자와_지역은_복합_유니크_제약_조건을_가진다 { + + @Test + void 같은_사용자가_같은_지역에_관심_표시를_하면_예외가_발생한다() { + // given + SiteUser user = siteUserFixture.사용자(); + Region region = regionFixture.영미권(); + + InterestedRegion firstInterest = new InterestedRegion(user, region); + interestedRegionRepository.save(firstInterest); + + InterestedRegion secondInterest = new InterestedRegion(user, region); + + // when & then + assertThatCode(() -> interestedRegionRepository.save(secondInterest)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 다른_사용자가_같은_지역에_관심_표시를_하면_정상_저장된다() { + // given + SiteUser user1 = siteUserFixture.사용자(1, "user1"); + SiteUser user2 = siteUserFixture.사용자(2, "user2"); + Region region = regionFixture.영미권(); + + InterestedRegion firstInterest = new InterestedRegion(user1, region); + interestedRegionRepository.save(firstInterest); + + InterestedRegion secondInterest = new InterestedRegion(user2, region); + + // when & then + assertThatCode(() -> { + InterestedRegion saved = interestedRegionRepository.save(secondInterest); + assertThat(saved.getId()).isNotNull(); + }).doesNotThrowAnyException(); + } + + @Test + void 같은_사용자가_다른_지역에_관심_표시를_하면_정상_저장된다() { + // given + SiteUser user = siteUserFixture.사용자(); + Region region1 = regionFixture.영미권(); + Region region2 = regionFixture.유럽(); + + InterestedRegion firstInterest = new InterestedRegion(user, region1); + interestedRegionRepository.save(firstInterest); + + InterestedRegion secondInterest = new InterestedRegion(user, region2); + + // when & then + assertThatCode(() -> { + InterestedRegion saved = interestedRegionRepository.save(secondInterest); + assertThat(saved.getId()).isNotNull(); + }).doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/location/region/repository/RegionRepositoryForTest.java b/src/test/java/com/example/solidconnection/location/region/repository/RegionRepositoryForTest.java new file mode 100644 index 000000000..05935f63f --- /dev/null +++ b/src/test/java/com/example/solidconnection/location/region/repository/RegionRepositoryForTest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.location.region.repository; + +import com.example.solidconnection.location.region.domain.Region; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepositoryForTest extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/ChannelFixture.java b/src/test/java/com/example/solidconnection/mentor/fixture/ChannelFixture.java new file mode 100644 index 000000000..d3c27c114 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/fixture/ChannelFixture.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.mentor.fixture; + +import com.example.solidconnection.mentor.domain.Channel; +import com.example.solidconnection.mentor.domain.ChannelType; +import com.example.solidconnection.mentor.domain.Mentor; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChannelFixture { + + private final ChannelFixtureBuilder channelFixtureBuilder; + + public Channel 채널(int sequence, Mentor mentor) { + return channelFixtureBuilder.channel() + .sequence(sequence) + .type(ChannelType.YOUTUBE) + .url("https://www.youtube.com/channel" + sequence) + .mentor(mentor) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/ChannelFixtureBuilder.java b/src/test/java/com/example/solidconnection/mentor/fixture/ChannelFixtureBuilder.java new file mode 100644 index 000000000..670b0e8b4 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/fixture/ChannelFixtureBuilder.java @@ -0,0 +1,56 @@ +package com.example.solidconnection.mentor.fixture; + +import com.example.solidconnection.mentor.domain.Channel; +import com.example.solidconnection.mentor.domain.ChannelType; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.repository.ChannelRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChannelFixtureBuilder { + + private final ChannelRepository channelRepository; + + private int sequence; + private ChannelType type; + private String url; + private Mentor mentor; + + public ChannelFixtureBuilder channel() { + return new ChannelFixtureBuilder(channelRepository); + } + + public ChannelFixtureBuilder sequence(int sequence) { + this.sequence = sequence; + return this; + } + + public ChannelFixtureBuilder type(ChannelType type) { + this.type = type; + return this; + } + + public ChannelFixtureBuilder url(String url) { + this.url = url; + return this; + } + + public ChannelFixtureBuilder mentor(Mentor mentor) { + this.mentor = mentor; + return this; + } + + public Channel create() { + Channel channel = new Channel( + null, + sequence, + type, + url, + null + ); + channel.updateMentor(mentor); + return channelRepository.save(channel); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentorFixture.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentorFixture.java new file mode 100644 index 000000000..b612a9417 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentorFixture.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.mentor.fixture; + +import com.example.solidconnection.mentor.domain.Mentor; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class MentorFixture { + + private final MentorFixtureBuilder mentorFixtureBuilder; + + public Mentor 멘토(long siteUserId, long universityId) { + return mentorFixtureBuilder.mentor() + .siteUserId(siteUserId) + .universityId(universityId) + .introduction("멘토 소개") + .passTip("합격 팁") + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentorFixtureBuilder.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentorFixtureBuilder.java new file mode 100644 index 000000000..7eaaefa94 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentorFixtureBuilder.java @@ -0,0 +1,75 @@ +package com.example.solidconnection.mentor.fixture; + +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.repository.MentorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class MentorFixtureBuilder { + + private final MentorRepository mentorRepository; + + private int menteeCount = 0; + private boolean hasBadge = false; + private String introduction; + private String passTip; + private long siteUserId; + private long universityId; + private String term = "2025-1"; + + public MentorFixtureBuilder mentor() { + return new MentorFixtureBuilder(mentorRepository); + } + + public MentorFixtureBuilder menteeCount(int menteeCount) { + this.menteeCount = menteeCount; + return this; + } + + public MentorFixtureBuilder hasBadge(boolean hasBadge) { + this.hasBadge = hasBadge; + return this; + } + + public MentorFixtureBuilder introduction(String introduction) { + this.introduction = introduction; + return this; + } + + public MentorFixtureBuilder passTip(String passTip) { + this.passTip = passTip; + return this; + } + + public MentorFixtureBuilder siteUserId(Long siteUserId) { + this.siteUserId = siteUserId; + return this; + } + + public MentorFixtureBuilder universityId(Long universityId) { + this.universityId = universityId; + return this; + } + + public MentorFixtureBuilder term(String term) { + this.term = term; + return this; + } + + public Mentor create() { + Mentor mentor = new Mentor( + null, + menteeCount, + hasBadge, + introduction, + passTip, + siteUserId, + universityId, + term, + null + ); + return mentorRepository.save(mentor); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentoringFixture.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentoringFixture.java new file mode 100644 index 000000000..4779c643e --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentoringFixture.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.mentor.fixture; + +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.MICROS; + +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.mentor.domain.Mentoring; +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class MentoringFixture { + + private final MentoringFixtureBuilder mentoringFixtureBuilder; + + public Mentoring 대기중_멘토링(long mentorId, long menteeId) { + return mentoringFixtureBuilder.mentoring() + .mentorId(mentorId) + .menteeId(menteeId) + .create(); + } + + public Mentoring 승인된_멘토링(long mentorId, long menteeId) { + ZonedDateTime now = getCurrentTime(); + return mentoringFixtureBuilder.mentoring() + .mentorId(mentorId) + .menteeId(menteeId) + .verifyStatus(VerifyStatus.APPROVED) + .confirmedAt(now) + .checkedAtByMentor(now) + .create(); + } + + public Mentoring 거절된_멘토링(long mentorId, long menteeId) { + ZonedDateTime now = getCurrentTime(); + return mentoringFixtureBuilder.mentoring() + .mentorId(mentorId) + .menteeId(menteeId) + .verifyStatus(VerifyStatus.REJECTED) + .confirmedAt(now) + .checkedAtByMentor(now) + .create(); + } + + public Mentoring 확인되지_않은_멘토링(long mentorId, long menteeId) { + return mentoringFixtureBuilder.mentoring() + .mentorId(mentorId) + .menteeId(menteeId) + .checkedAtByMentor(null) + .create(); + } + + private ZonedDateTime getCurrentTime() { + return ZonedDateTime.now(UTC).truncatedTo(MICROS); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentoringFixtureBuilder.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentoringFixtureBuilder.java new file mode 100644 index 000000000..f4e905b3a --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentoringFixtureBuilder.java @@ -0,0 +1,76 @@ +package com.example.solidconnection.mentor.fixture; + +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class MentoringFixtureBuilder { + + private final MentoringRepository mentoringRepository; + + private ZonedDateTime createdAt; + private ZonedDateTime confirmedAt; + private ZonedDateTime checkedAtByMentor; + private ZonedDateTime checkedAtByMentee; + private VerifyStatus verifyStatus = VerifyStatus.PENDING; + private long mentorId; + private long menteeId; + + public MentoringFixtureBuilder mentoring() { + return new MentoringFixtureBuilder(mentoringRepository); + } + + public MentoringFixtureBuilder createdAt(ZonedDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public MentoringFixtureBuilder confirmedAt(ZonedDateTime confirmedAt) { + this.confirmedAt = confirmedAt; + return this; + } + + public MentoringFixtureBuilder checkedAtByMentor(ZonedDateTime checkedAtByMentor) { + this.checkedAtByMentor = checkedAtByMentor; + return this; + } + + public MentoringFixtureBuilder checkedAtByMentee(ZonedDateTime checkedAtByMentor) { + this.checkedAtByMentor = checkedAtByMentor; + return this; + } + + public MentoringFixtureBuilder verifyStatus(VerifyStatus verifyStatus) { + this.verifyStatus = verifyStatus; + return this; + } + + public MentoringFixtureBuilder mentorId(long mentorId) { + this.mentorId = mentorId; + return this; + } + + public MentoringFixtureBuilder menteeId(long menteeId) { + this.menteeId = menteeId; + return this; + } + + public Mentoring create() { + Mentoring mentoring = new Mentoring( + null, + createdAt, + confirmedAt, + checkedAtByMentor, + checkedAtByMentee, + verifyStatus, + mentorId, + menteeId + ); + return mentoringRepository.save(mentoring); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/repository/ChannelRepositoryForTest.java b/src/test/java/com/example/solidconnection/mentor/repository/ChannelRepositoryForTest.java new file mode 100644 index 000000000..16000dddc --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/repository/ChannelRepositoryForTest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.mentor.repository; + +import com.example.solidconnection.mentor.domain.Channel; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChannelRepositoryForTest extends JpaRepository { + + List findAllByMentorId(long mentorId); +} diff --git a/src/test/java/com/example/solidconnection/mentor/repository/MentorBatchQueryRepositoryTest.java b/src/test/java/com/example/solidconnection/mentor/repository/MentorBatchQueryRepositoryTest.java new file mode 100644 index 000000000..347bb684f --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/repository/MentorBatchQueryRepositoryTest.java @@ -0,0 +1,100 @@ +package com.example.solidconnection.mentor.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.fixture.MentoringFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.fixture.UniversityFixture; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("멘토 배치 조회 레포지토리 테스트") +@TestContainerSpringBootTest +class MentorBatchQueryRepositoryTest { + + @Autowired + private MentorBatchQueryRepository mentorBatchQueryRepository; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private MentoringFixture mentoringFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private UniversityFixture universityFixture; + + private University university1, university2; + private Mentor mentor1, mentor2; + private SiteUser mentorUser1, mentorUser2, currentUser; + + @BeforeEach + void setUp() { + currentUser = siteUserFixture.사용자(1, "사용자"); + mentorUser1 = siteUserFixture.사용자(2, "멘토1"); + mentorUser2 = siteUserFixture.사용자(3, "멘토2"); + university1 = universityFixture.코펜하겐IT_대학(); + university2 = universityFixture.메모리얼_대학_세인트존스(); + mentor1 = mentorFixture.멘토(mentorUser1.getId(), university1.getId()); + mentor2 = mentorFixture.멘토(mentorUser2.getId(), university2.getId()); + } + + @Test + void 멘토_ID_와_멘토_사용자를_매핑한다() { + // given + List mentors = List.of(mentor1, mentor2); + + // when + Map mentorIdToSiteUser = mentorBatchQueryRepository.getMentorIdToSiteUserMap(mentors); + + // then + assertAll( + () -> assertThat(mentorIdToSiteUser.get(mentor1.getId()).getId()).isEqualTo(mentorUser1.getId()), + () -> assertThat(mentorIdToSiteUser.get(mentor2.getId()).getId()).isEqualTo(mentorUser2.getId()) + ); + } + + @Test + void 멘토_ID_와_멘토의_파견_대학교를_매핑한다() { + // given + List mentors = List.of(mentor1, mentor2); + + // when + Map mentorIdToUniversity = mentorBatchQueryRepository.getMentorIdToUniversityMap(mentors); + + // then + assertAll( + () -> assertThat(mentorIdToUniversity.get(mentor1.getId()).getId()).isEqualTo(university1.getId()), + () -> assertThat(mentorIdToUniversity.get(mentor2.getId()).getId()).isEqualTo(university2.getId()) + ); + } + + @Test + void 멘토_ID_와_현재_사용자의_지원_여부를_매핑한다() { + // given + mentoringFixture.대기중_멘토링(mentor1.getId(), currentUser.getId()); + List mentors = List.of(mentor1, mentor2); + + // when + Map mentorIdToIsApplied = mentorBatchQueryRepository.getMentorIdToIsApplied(mentors, currentUser.getId()); + + // then + assertAll( + () -> assertThat(mentorIdToIsApplied.get(mentor1.getId())).isTrue(), + () -> assertThat(mentorIdToIsApplied.get(mentor2.getId())).isFalse() + ); + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java new file mode 100644 index 000000000..f4bc6a0ea --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java @@ -0,0 +1,153 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.mentor.domain.ChannelType.BLOG; +import static com.example.solidconnection.mentor.domain.ChannelType.INSTAGRAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.mentor.domain.Channel; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.dto.ChannelRequest; +import com.example.solidconnection.mentor.dto.ChannelResponse; +import com.example.solidconnection.mentor.dto.MentorMyPageResponse; +import com.example.solidconnection.mentor.dto.MentorMyPageUpdateRequest; +import com.example.solidconnection.mentor.fixture.ChannelFixture; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.repository.ChannelRepositoryForTest; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.fixture.UniversityFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("멘토 마이페이지 서비스 테스트") +class MentorMyPageServiceTest { + + @Autowired + private MentorMyPageService mentorMyPageService; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private ChannelFixture channelFixture; + + @Autowired + private MentorRepository mentorRepository; + + @Autowired + private UniversityFixture universityFixture; + + @Autowired + private ChannelRepositoryForTest channelRepositoryForTest; + + private SiteUser mentorUser; + private Mentor mentor; + private University university; + + @BeforeEach + void setUp() { + university = universityFixture.메이지_대학(); + mentorUser = siteUserFixture.멘토(1, "멘토"); + mentor = mentorFixture.멘토(mentorUser.getId(), university.getId()); + } + + @Nested + class 멘토의_마이_페이지를_조회한다 { + + @Test + void 성공적으로_조회한다() { + // given + Channel channel1 = channelFixture.채널(1, mentor); + Channel channel2 = channelFixture.채널(2, mentor); + + // when + MentorMyPageResponse response = mentorMyPageService.getMentorMyPage(mentorUser.getId()); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(mentor.getId()), + () -> assertThat(response.nickname()).isEqualTo(mentorUser.getNickname()), + () -> assertThat(response.universityName()).isEqualTo(university.getKoreanName()), + () -> assertThat(response.country()).isEqualTo(university.getCountry().getKoreanName()), + () -> assertThat(response.channels()).extracting(ChannelResponse::url) + .containsExactly(channel1.getUrl(), channel2.getUrl()) + ); + } + } + + @Nested + class 멘토의_마이_페이지를_수정한다 { + + @Test + void 멘토_정보를_수정한다() { + // given + String newIntroduction = "새로운 자기소개"; + String newPassTip = "새로운 합격 팁"; + MentorMyPageUpdateRequest request = new MentorMyPageUpdateRequest(newIntroduction, newPassTip, List.of()); + + // when + mentorMyPageService.updateMentorMyPage(mentorUser.getId(), request); + + // then + Mentor updatedMentor = mentorRepository.findById(mentor.getId()).get(); + assertAll( + () -> assertThat(updatedMentor.getIntroduction()).isEqualTo(newIntroduction), + () -> assertThat(updatedMentor.getPassTip()).isEqualTo(newPassTip) + ); + } + + @Test + void 기존보다_적게_채널_정보를_수정한다() { + // given + channelFixture.채널(1, mentor); + channelFixture.채널(2, mentor); + channelFixture.채널(3, mentor); + channelFixture.채널(4, mentor); + List newChannels = List.of(new ChannelRequest(BLOG, "https://blog.com")); + MentorMyPageUpdateRequest request = new MentorMyPageUpdateRequest("introduction", "passTip", newChannels); + + // when + mentorMyPageService.updateMentorMyPage(mentorUser.getId(), request); + + // then + List updatedChannels = channelRepositoryForTest.findAllByMentorId(mentor.getId()); + assertThat(updatedChannels).extracting(Channel::getSequence, Channel::getType, Channel::getUrl) + .containsExactlyInAnyOrder(tuple(1, BLOG, "https://blog.com")); + } + + @Test + void 기존보다_많게_채널_정보를_수정한다() { + // given + channelFixture.채널(1, mentor); + List newChannels = List.of( + new ChannelRequest(BLOG, "https://blog.com"), + new ChannelRequest(INSTAGRAM, "https://instagram.com") + ); + MentorMyPageUpdateRequest request = new MentorMyPageUpdateRequest("introduction", "passTip", newChannels); + + // when + mentorMyPageService.updateMentorMyPage(mentorUser.getId(), request); + + // then + List updatedChannels = channelRepositoryForTest.findAllByMentorId(mentor.getId()); + assertThat(updatedChannels).extracting(Channel::getSequence, Channel::getType, Channel::getUrl) + .containsExactlyInAnyOrder( + tuple(1, BLOG, "https://blog.com"), + tuple(2, INSTAGRAM, "https://instagram.com") + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentorQueryServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentorQueryServiceTest.java new file mode 100644 index 000000000..d20bc28d7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/service/MentorQueryServiceTest.java @@ -0,0 +1,241 @@ +package com.example.solidconnection.mentor.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.mentor.domain.Channel; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.dto.ChannelResponse; +import com.example.solidconnection.mentor.dto.MentorDetailResponse; +import com.example.solidconnection.mentor.dto.MentorPreviewResponse; +import com.example.solidconnection.mentor.fixture.ChannelFixture; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.fixture.MentoringFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.fixture.UniversityFixture; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +@DisplayName("멘토 조회 서비스 테스트") +@TestContainerSpringBootTest +class MentorQueryServiceTest { + + @Autowired + private MentorQueryService mentorQueryService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private MentoringFixture mentoringFixture; + + @Autowired + private ChannelFixture channelFixture; + + @Autowired + private UniversityFixture universityFixture; + + private University university; + + @BeforeEach + void setUp() { + university = universityFixture.그라츠_대학(); + } + + @Nested + class 멘토_단일_조회_성공 { + + @Test + void 멘토_정보를_조회한다() { + // given + SiteUser siteUser = siteUserFixture.사용자(); + SiteUser mentorUser = siteUserFixture.사용자(1, "멘토"); + Mentor mentor = mentorFixture.멘토(mentorUser.getId(), university.getId()); + Channel channel1 = channelFixture.채널(1, mentor); + Channel channel2 = channelFixture.채널(2, mentor); + + // when + MentorDetailResponse response = mentorQueryService.getMentorDetails(mentor.getId(), siteUser.getId()); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(mentor.getId()), + () -> assertThat(response.nickname()).isEqualTo(mentorUser.getNickname()), + () -> assertThat(response.universityName()).isEqualTo(university.getKoreanName()), + () -> assertThat(response.country()).isEqualTo(university.getCountry().getKoreanName()), + () -> assertThat(response.channels()).extracting(ChannelResponse::url) + .containsExactly(channel1.getUrl(), channel2.getUrl()) + ); + } + + @Test + void 멘토에_대한_나의_멘토링_신청_여부를_조회한다() { + // given + SiteUser mentorUser = siteUserFixture.사용자(1, "멘토"); + Mentor mentor = mentorFixture.멘토(mentorUser.getId(), university.getId()); + + SiteUser notAppliedUser = siteUserFixture.사용자(2, "멘토링 지원 안한 사용자"); + SiteUser appliedUser = siteUserFixture.사용자(3, "멘토링 지원한 사용자"); + mentoringFixture.대기중_멘토링(mentor.getId(), appliedUser.getId()); + + // when + MentorDetailResponse notAppliedResponse = mentorQueryService.getMentorDetails(mentor.getId(), notAppliedUser.getId()); + MentorDetailResponse appliedResponse = mentorQueryService.getMentorDetails(mentor.getId(), appliedUser.getId()); + + // then + assertAll( + () -> assertThat(notAppliedResponse.isApplied()).isFalse(), + () -> assertThat(appliedResponse.isApplied()).isTrue() + ); + } + } + + @Nested + class 멘토_단일_조회_실패 { + + @Test + void 존재하지_않는_멘토를_조회하면_예외가_발생한다() { + // given + long notExistingMentorId = 999L; + + // when & then + assertThatCode(() -> mentorQueryService.getMentorDetails(notExistingMentorId, siteUserFixture.사용자().getId())) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.MENTOR_NOT_FOUND.getMessage()); + } + } + + @Nested + class 멘토_미리보기_목록_정보_조회 { + + private static final int NO_NEXT_PAGE_NUMBER = -1; + + private Mentor mentor1, mentor2; + private SiteUser mentorUser1, mentorUser2, currentUser; + private University university1, university2; + + @BeforeEach + void setUp() { + currentUser = siteUserFixture.사용자(1, "사용자1"); + mentorUser1 = siteUserFixture.사용자(2, "멘토1"); + mentorUser2 = siteUserFixture.사용자(3, "멘토2"); + university1 = universityFixture.괌_대학(); + university2 = universityFixture.린츠_카톨릭_대학(); + mentor1 = mentorFixture.멘토(mentorUser1.getId(), university1.getId()); + mentor2 = mentorFixture.멘토(mentorUser2.getId(), university2.getId()); + } + + @Test + void 멘토_미리보기_목록의_정보를_조회한다() { + // given + Channel channel1 = channelFixture.채널(1, mentor1); + Channel channel2 = channelFixture.채널(2, mentor2); + + // when + SliceResponse response = mentorQueryService.getMentorPreviews("", currentUser.getId(), PageRequest.of(0, 10)); + + // then + Map mentorPreviewMap = response.content().stream() + .collect(Collectors.toMap(MentorPreviewResponse::id, Function.identity())); + MentorPreviewResponse mentor1Response = mentorPreviewMap.get(mentor1.getId()); + MentorPreviewResponse mentor2Response = mentorPreviewMap.get(mentor2.getId()); + assertAll( + () -> assertThat(mentor1Response.nickname()).isEqualTo(mentorUser1.getNickname()), + () -> assertThat(mentor1Response.universityName()).isEqualTo(university1.getKoreanName()), + () -> assertThat(mentor1Response.country()).isEqualTo(university1.getCountry().getKoreanName()), + () -> assertThat(mentor1Response.channels()).extracting(ChannelResponse::url) + .containsOnly(channel1.getUrl()), + + () -> assertThat(mentor2Response.nickname()).isEqualTo(mentorUser2.getNickname()), + () -> assertThat(mentor2Response.universityName()).isEqualTo(university2.getKoreanName()), + () -> assertThat(mentor2Response.country()).isEqualTo(university2.getCountry().getKoreanName()), + () -> assertThat(mentor2Response.channels()).extracting(ChannelResponse::url) + .containsOnly(channel2.getUrl()) + ); + } + + @Test + void 다음_페이지_번호를_응답한다() { + // given + SliceResponse response = mentorQueryService.getMentorPreviews("", currentUser.getId(), PageRequest.of(0, 1)); + + // then + assertThat(response.nextPageNumber()).isEqualTo(2); + } + + @Test + void 다음_페이지가_없으면_페이지_없음을_의미하는_값을_응답한다() { + // given + SliceResponse response = mentorQueryService.getMentorPreviews("", currentUser.getId(), PageRequest.of(0, 10)); + + // then + assertThat(response.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER); + } + } + + @Nested + class 멘토_미리보기_목록_필터링 { + + private Mentor asiaMentor, europeMentor; + private SiteUser currentUser; + private University asiaUniversity, europeUniversity; + + @BeforeEach + void setUp() { + currentUser = siteUserFixture.사용자(1, "사용자1"); + SiteUser mentorUser1 = siteUserFixture.사용자(2, "멘토1"); + SiteUser mentorUser2 = siteUserFixture.사용자(3, "멘토2"); + asiaUniversity = universityFixture.메이지_대학(); + europeUniversity = universityFixture.린츠_카톨릭_대학(); + asiaMentor = mentorFixture.멘토(mentorUser1.getId(), asiaUniversity.getId()); + europeMentor = mentorFixture.멘토(mentorUser2.getId(), europeUniversity.getId()); + } + + @Test + void 권역으로_멘토_목록을_필터링한다() { + // when + SliceResponse asiaFilteredResponse = mentorQueryService.getMentorPreviews( + asiaUniversity.getRegion().getKoreanName(), currentUser.getId(), PageRequest.of(0, 10)); + SliceResponse europeFilteredResponse = mentorQueryService.getMentorPreviews( + europeUniversity.getRegion().getKoreanName(), currentUser.getId(), PageRequest.of(0, 10)); + + // then + assertAll( + () -> assertThat(asiaFilteredResponse.content()).hasSize(1) + .extracting(MentorPreviewResponse::id) + .containsExactly(asiaMentor.getId()), + () -> assertThat(europeFilteredResponse.content()).hasSize(1) + .extracting(MentorPreviewResponse::id) + .containsExactly(europeMentor.getId()) + ); + } + + @Test + void 권역을_지정하지_않으면_전체_멘토_목록을_조회한다() { + // when + SliceResponse response = mentorQueryService.getMentorPreviews("", currentUser.getId(), PageRequest.of(0, 10)); + + // then + assertThat(response.content()).hasSize(2) + .extracting(MentorPreviewResponse::id) + .containsExactlyInAnyOrder(asiaMentor.getId(), europeMentor.getId()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentoringCheckServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentoringCheckServiceTest.java new file mode 100644 index 000000000..c5f8a9463 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/service/MentoringCheckServiceTest.java @@ -0,0 +1,171 @@ +package com.example.solidconnection.mentor.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.CheckMentoringRequest; +import com.example.solidconnection.mentor.dto.CheckedMentoringsResponse; +import com.example.solidconnection.mentor.dto.MentoringCountResponse; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.fixture.MentoringFixture; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("멘토링 확인 서비스 테스트") +class MentoringCheckServiceTest { + + @Autowired + private MentoringRepository mentoringRepository; + + @Autowired + private MentoringCheckService mentoringCheckService; + + @Autowired + private MentoringFixture mentoringFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private MentorFixture mentorFixture; + + + private SiteUser mentorUser1, mentorUser2; + private SiteUser menteeUser1, menteeUser2, menteeUser3; + private Mentor mentor1, mentor2, mentor3; + + @BeforeEach + void setUp() { + mentorUser1 = siteUserFixture.멘토(1, "mentor1"); + mentorUser2 = siteUserFixture.멘토(2, "mentor2"); + SiteUser mentorUser3 = siteUserFixture.멘토(3, "mentor3"); + menteeUser1 = siteUserFixture.사용자(1, "mentee1"); + menteeUser2 = siteUserFixture.사용자(2, "mentee2"); + menteeUser3 = siteUserFixture.사용자(3, "mentee3"); + mentor1 = mentorFixture.멘토(mentorUser1.getId(), 1L); + mentor2 = mentorFixture.멘토(mentorUser2.getId(), 1L); + mentor3 = mentorFixture.멘토(mentorUser3.getId(), 1L); + } + + @Nested + class 멘토의_멘토링_확인_테스트 { + + @Test + void 멘토가_멘토링을_확인한다() { + // given + Mentoring mentoring1 = mentoringFixture.확인되지_않은_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.확인되지_않은_멘토링(mentor1.getId(), menteeUser2.getId()); + Mentoring mentoring3 = mentoringFixture.확인되지_않은_멘토링(mentor1.getId(), menteeUser3.getId()); + CheckMentoringRequest request = new CheckMentoringRequest(List.of(mentoring1.getId(), mentoring2.getId())); + + // when + CheckedMentoringsResponse response = mentoringCheckService.checkMentoringsForMentor(mentorUser1.getId(), request); + + // then + assertAll( + () -> assertThat(response.checkedMentoringIds()).containsExactlyInAnyOrder(mentoring1.getId(), mentoring2.getId()), + () -> assertThat(mentoringRepository.findById(mentoring1.getId())) + .hasValueSatisfying(mentoring -> assertThat(mentoring.getCheckedAtByMentor()).isNotNull()), + () -> assertThat(mentoringRepository.findById(mentoring2.getId())) + .hasValueSatisfying(mentoring -> assertThat(mentoring.getCheckedAtByMentor()).isNotNull()), + () -> assertThat(mentoringRepository.findById(mentoring3.getId())) + .hasValueSatisfying(mentoring -> assertThat(mentoring.getCheckedAtByMentor()).isNull()) + ); + } + + @Test + void 다른_멘토의_멘토링은_확인하면_예외가_발생한다() { + // given + Mentoring mentoring2 = mentoringFixture.확인되지_않은_멘토링(mentor2.getId(), menteeUser2.getId()); + CheckMentoringRequest request = new CheckMentoringRequest(List.of(mentoring2.getId())); + + // when, then + assertThatCode(() -> mentoringCheckService.checkMentoringsForMentor(mentorUser1.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.UNAUTHORIZED_MENTORING.getMessage()); + } + } + + @Nested + class 멘티의_멘토링_확인_테스트 { + + @Test + void 멘티가_멘토링을_확인한다() { + // given + Mentoring mentoring1 = mentoringFixture.확인되지_않은_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.확인되지_않은_멘토링(mentor2.getId(), menteeUser1.getId()); + Mentoring mentoring3 = mentoringFixture.확인되지_않은_멘토링(mentor3.getId(), menteeUser1.getId()); + CheckMentoringRequest request = new CheckMentoringRequest(List.of(mentoring1.getId(), mentoring2.getId())); + + // when + CheckedMentoringsResponse response = mentoringCheckService.checkMentoringsForMentee(menteeUser1.getId(), request); + + // then + assertAll( + () -> assertThat(response.checkedMentoringIds()).containsExactlyInAnyOrder(mentoring1.getId(), mentoring2.getId()), + () -> assertThat(mentoringRepository.findById(mentoring1.getId())) + .hasValueSatisfying(mentoring -> assertThat(mentoring.getCheckedAtByMentee()).isNotNull()), + () -> assertThat(mentoringRepository.findById(mentoring2.getId())) + .hasValueSatisfying(mentoring -> assertThat(mentoring.getCheckedAtByMentee()).isNotNull()), + () -> assertThat(mentoringRepository.findById(mentoring3.getId())) + .hasValueSatisfying(mentoring -> assertThat(mentoring.getCheckedAtByMentee()).isNull()) + ); + } + + @Test + void 다른_멘티의_멘토링을_확인하면_예외가_발생한다() { + // given + Mentoring mentoring2 = mentoringFixture.확인되지_않은_멘토링(mentor2.getId(), menteeUser2.getId()); + CheckMentoringRequest request = new CheckMentoringRequest(List.of(mentoring2.getId())); + + // when, then + assertThatCode(() -> mentoringCheckService.checkMentoringsForMentee(menteeUser1.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.UNAUTHORIZED_MENTORING.getMessage()); + } + + @Nested + class 멘토가_확인하지_않은_멘토링_개수_조회_테스트 { + + @Test + void 멘토가_확인하지_않은_멘토링_개수를_반환한다() { + // given + mentoringFixture.확인되지_않은_멘토링(mentor1.getId(), menteeUser1.getId()); + mentoringFixture.확인되지_않은_멘토링(mentor1.getId(), menteeUser2.getId()); + mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser3.getId()); + + // when + MentoringCountResponse response = mentoringCheckService.getUncheckedMentoringCount(mentorUser1.getId()); + + // then + assertThat(response.uncheckedCount()).isEqualTo(2); + } + + @Test + void 멘토가_확인하지_않은_멘토링이_없으면_0을_반환한다() { + // given + mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser1.getId()); + + // when + MentoringCountResponse response = mentoringCheckService.getUncheckedMentoringCount(mentorUser1.getId()); + + // then + assertThat(response.uncheckedCount()).isZero(); + } + } + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java new file mode 100644 index 000000000..8c6a78468 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java @@ -0,0 +1,229 @@ +package com.example.solidconnection.mentor.service; + +import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_ALREADY_CONFIRMED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.UNAUTHORIZED_MENTORING; +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.awaitility.Awaitility.await; +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatRoomRepositoryForTest; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.MentoringApplyRequest; +import com.example.solidconnection.mentor.dto.MentoringApplyResponse; +import com.example.solidconnection.mentor.dto.MentoringConfirmRequest; +import com.example.solidconnection.mentor.dto.MentoringConfirmResponse; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.fixture.MentoringFixture; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("멘토링 CUD 서비스 테스트") +class MentoringCommandServiceTest { + + @Autowired + private MentoringCommandService mentoringCommandService; + + @Autowired + private MentorRepository mentorRepository; + + @Autowired + private MentoringRepository mentoringRepository; + + @Autowired + private ChatRoomRepositoryForTest chatRoomRepositoryForTest; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private MentoringFixture mentoringFixture; + + private SiteUser mentorUser1; + private SiteUser mentorUser2; + + private SiteUser menteeUser; + private Mentor mentor1; + private Mentor mentor2; + + @BeforeEach + void setUp() { + mentorUser1 = siteUserFixture.멘토(1, "mentor1"); + menteeUser = siteUserFixture.사용자(2, "mentee1"); + mentorUser2 = siteUserFixture.멘토(3, "mentor2"); + + mentor1 = mentorFixture.멘토(mentorUser1.getId(), 1L); + mentor2 = mentorFixture.멘토(mentorUser2.getId(), 2L); + } + + @Nested + class 멘토링_신청_테스트 { + + @Test + void 멘토링을_성공적으로_신청한다() { + // given + MentoringApplyRequest request = new MentoringApplyRequest(mentor1.getId()); + + // when + MentoringApplyResponse response = mentoringCommandService.applyMentoring(menteeUser.getId(), request); + + // then + Mentoring mentoring = mentoringRepository.findById(response.mentoringId()).orElseThrow(); + + assertAll( + () -> assertThat(mentoring.getMentorId()).isEqualTo(mentor1.getId()), + () -> assertThat(mentoring.getMenteeId()).isEqualTo(menteeUser.getId()), + () -> assertThat(mentoring.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING) + ); + } + } + + @Nested + class 멘토링_승인_거절_테스트 { + + @Test + void 멘토링을_성공적으로_승인한다() { + // given + Mentoring mentoring = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.APPROVED); + int beforeMenteeCount = mentor1.getMenteeCount(); + + // when + MentoringConfirmResponse response = mentoringCommandService.confirmMentoring(mentorUser1.getId(), mentoring.getId(), request); + + // then + Mentoring confirmedMentoring = mentoringRepository.findById(response.mentoringId()).orElseThrow(); + Mentor mentor = mentorRepository.findById(mentor1.getId()).orElseThrow(); + + assertAll( + () -> assertThat(confirmedMentoring.getVerifyStatus()).isEqualTo(VerifyStatus.APPROVED), + () -> assertThat(confirmedMentoring.getConfirmedAt()).isNotNull(), + () -> assertThat(confirmedMentoring.getCheckedAtByMentor()).isNotNull(), + () -> assertThat(confirmedMentoring.getCheckedAtByMentee()).isNull(), + () -> assertThat(mentor.getMenteeCount()).isEqualTo(beforeMenteeCount + 1) + ); + } + + @Test + void 멘토링_승인시_채팅방이_자동으로_생성된다() { + // given + Mentoring mentoring = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.APPROVED); + + Optional beforeChatRoom = chatRoomRepositoryForTest.findOneOnOneChatRoomByParticipants(mentorUser1.getId(), menteeUser.getId()); + assertThat(beforeChatRoom).isEmpty(); + + // when + MentoringConfirmResponse response = mentoringCommandService.confirmMentoring(mentorUser1.getId(), mentoring.getId(), request); + + // then + ChatRoom afterChatRoom = chatRoomRepositoryForTest.findOneOnOneChatRoomByParticipants(mentorUser1.getId(), menteeUser.getId()).orElseThrow(); + List participantIds = afterChatRoom.getChatParticipants().stream() + .map(ChatParticipant::getSiteUserId) + .toList(); + assertAll( + () -> assertThat(afterChatRoom.isGroup()).isFalse(), + () -> assertThat(participantIds).containsExactly(mentorUser1.getId(), menteeUser.getId()), + () -> assertThat(response.chatRoomId()).isEqualTo(afterChatRoom.getId()) + ); + } + + @Test + void 멘토링을_성공적으로_거절한다() { + // given + Mentoring mentoring = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.REJECTED); + int beforeMenteeCount = mentor1.getMenteeCount(); + + // when + MentoringConfirmResponse response = mentoringCommandService.confirmMentoring(mentorUser1.getId(), mentoring.getId(), request); + + // then + Mentoring confirmedMentoring = mentoringRepository.findById(response.mentoringId()).orElseThrow(); + Mentor mentor = mentorRepository.findById(mentor1.getId()).orElseThrow(); + + assertAll( + () -> assertThat(confirmedMentoring.getVerifyStatus()).isEqualTo(VerifyStatus.REJECTED), + () -> assertThat(confirmedMentoring.getConfirmedAt()).isNotNull(), + () -> assertThat(confirmedMentoring.getCheckedAtByMentor()).isNotNull(), + () -> assertThat(mentor.getMenteeCount()).isEqualTo(beforeMenteeCount) + ); + } + + @Test + void 멘토링_거절시_채팅방이_자동으로_생성되지_않는다() { + // given + Mentoring mentoring = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.REJECTED); + + Optional beforeChatRoom = chatRoomRepositoryForTest.findOneOnOneChatRoomByParticipants(mentorUser1.getId(), menteeUser.getId()); + assertThat(beforeChatRoom).isEmpty(); + + // when + MentoringConfirmResponse response = mentoringCommandService.confirmMentoring(mentorUser1.getId(), mentoring.getId(), request); + + // then + Optional afterChatRoom = chatRoomRepositoryForTest.findOneOnOneChatRoomByParticipants(mentorUser1.getId(), menteeUser.getId()); + assertAll( + () -> assertThat(response.chatRoomId()).isNull(), + () -> assertThat(afterChatRoom).isEmpty() + ); + } + + @Test + void 다른_멘토의_멘토링을_승인할_수_없다() { + // given + Mentoring mentoring = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.APPROVED); + + // when & then + assertThatThrownBy(() -> mentoringCommandService.confirmMentoring(mentorUser2.getId(), mentoring.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(UNAUTHORIZED_MENTORING.getMessage()); + } + + @Test + void 이미_처리된_멘토링은_다시_승인할_수_없다() { + // given + Mentoring mentoring = mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.APPROVED); + + // when & then + assertThatThrownBy(() -> mentoringCommandService.confirmMentoring(mentorUser1.getId(), mentoring.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTORING_ALREADY_CONFIRMED.getMessage()); + } + + @Test + void 존재하지_않는_멘토링_아이디로_요청시_예외가_발생한다() { + // given + MentoringConfirmRequest request = new MentoringConfirmRequest(VerifyStatus.APPROVED); + long invalidMentoringId = 9999L; + + // when & then + assertThatThrownBy(() -> mentoringCommandService.confirmMentoring(mentorUser1.getId(), invalidMentoringId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTORING_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java new file mode 100644 index 000000000..867af6e56 --- /dev/null +++ b/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java @@ -0,0 +1,242 @@ +package com.example.solidconnection.mentor.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; + +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.fixture.ChatRoomFixture; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.MentoringForMenteeResponse; +import com.example.solidconnection.mentor.dto.MentoringForMentorResponse; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.fixture.MentoringFixture; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@TestContainerSpringBootTest +@DisplayName("멘토링 조회 서비스 테스트") +class MentoringQueryServiceTest { + + @Autowired + private MentoringQueryService mentoringQueryService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private MentoringFixture mentoringFixture; + + @Autowired + private MentoringRepository mentoringRepository; + + @Autowired + private ChatRoomFixture chatRoomFixture; + + private SiteUser mentorUser1, mentorUser2; + private SiteUser menteeUser1, menteeUser2, menteeUser3; + private Mentor mentor1, mentor2, mentor3; + private Pageable pageable; + + @BeforeEach + void setUp() { + mentorUser1 = siteUserFixture.멘토(1, "mentor1"); + mentorUser2 = siteUserFixture.멘토(2, "mentor2"); + SiteUser mentorUser3 = siteUserFixture.멘토(3, "mentor3"); + menteeUser1 = siteUserFixture.사용자(1, "mentee1"); + menteeUser2 = siteUserFixture.사용자(2, "mentee2"); + menteeUser3 = siteUserFixture.사용자(3, "mentee3"); + mentor1 = mentorFixture.멘토(mentorUser1.getId(), 1L); + mentor2 = mentorFixture.멘토(mentorUser2.getId(), 1L); + mentor3 = mentorFixture.멘토(mentorUser3.getId(), 1L); + pageable = PageRequest.of(0, 3); + } + + @Nested + class 멘토의_멘토링_목록_조회_테스트 { + + @Test + void 모든_상태의_멘토링_목록을_조회한다() { + // given + Mentoring mentoring1 = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser2.getId()); + Mentoring mentoring3 = mentoringFixture.거절된_멘토링(mentor1.getId(), menteeUser3.getId()); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentor(mentorUser1.getId(), pageable); + + // then + assertThat(response.content()).extracting(MentoringForMentorResponse::mentoringId) + .containsExactlyInAnyOrder( + mentoring1.getId(), + mentoring2.getId(), + mentoring3.getId() + ); + } + + @Test + void 멘토링_상대의_정보를_포함한다() { + // given + mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser1.getId()); + mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser2.getId()); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentor(mentorUser1.getId(), pageable); + + // then + assertThat(response.content()).extracting(MentoringForMentorResponse::nickname) + .containsExactlyInAnyOrder( + menteeUser1.getNickname(), + menteeUser2.getNickname() + ); + } + + @Test + void 멘토링_확인_여부를_포함한다() { + // given + Mentoring mentoring1 = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser2.getId()); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentor(mentorUser1.getId(), pageable); + + // then + assertThat(response.content()) + .extracting(MentoringForMentorResponse::mentoringId, MentoringForMentorResponse::isChecked) + .containsExactlyInAnyOrder( + tuple(mentoring1.getId(), false), + tuple(mentoring2.getId(), true) + ); + } + + @Test + void 멘토링이_없는_경우_빈_리스트를_반환한다() { + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentor(mentorUser1.getId(), pageable); + + // then + assertThat(response.content()).isEmpty(); + } + } + + @Nested + class 멘티의_멘토링_목록_조회_테스트 { + + @Test + void 거절된_멘토링_목록을_조회하면_예외가_발생한다() { + // given + mentoringFixture.거절된_멘토링(mentor1.getId(), menteeUser1.getId()); + + // when & then + assertThatCode(() -> mentoringQueryService.getMentoringsForMentee(menteeUser1.getId(), VerifyStatus.REJECTED, pageable)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.UNAUTHORIZED_MENTORING.getMessage()); + } + + @Test + void 승인된_멘토링_목록과_대응하는_채팅방을_조회한다() { + // given + Mentoring mentoring1 = mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.승인된_멘토링(mentor2.getId(), menteeUser1.getId()); + ChatRoom mentoringChatRoom1 = chatRoomFixture.멘토링_채팅방(mentoring1.getId()); + ChatRoom mentoringChatRoom2 = chatRoomFixture.멘토링_채팅방(mentoring2.getId()); + mentoringFixture.대기중_멘토링(mentor3.getId(), menteeUser1.getId()); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentee( + menteeUser1.getId(), VerifyStatus.APPROVED, pageable); + + // then + assertThat(response.content()).extracting(MentoringForMenteeResponse::mentoringId, MentoringForMenteeResponse::chatRoomId) + .containsExactlyInAnyOrder( + tuple(mentoring1.getId(), mentoringChatRoom1.getId()), + tuple(mentoring2.getId(), mentoringChatRoom2.getId()) + ); + } + + @Test + void 대기중인_멘토링_목록을_조회한다() { + // given + Mentoring mentoring1 = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.대기중_멘토링(mentor2.getId(), menteeUser1.getId()); + mentoringFixture.승인된_멘토링(mentor3.getId(), menteeUser1.getId()); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentee( + menteeUser1.getId(), VerifyStatus.PENDING, pageable); + + // then + assertThat(response.content()).extracting(MentoringForMenteeResponse::mentoringId) + .containsExactlyInAnyOrder( + mentoring1.getId(), + mentoring2.getId() + ); + } + + @Test + void 멘토링_상대의_정보를_포함한다() { + // given + mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser1.getId()); + mentoringFixture.승인된_멘토링(mentor2.getId(), menteeUser1.getId()); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentee( + menteeUser1.getId(), VerifyStatus.APPROVED, pageable); + + // then + assertThat(response.content()).extracting(MentoringForMenteeResponse::nickname) + .containsExactlyInAnyOrder( + mentorUser1.getNickname(), + mentorUser2.getNickname() + ); + } + + @Test + void 멘토링_확인_여부를_포함한다() { + // given + Mentoring mentoring1 = mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser1.getId()); + Mentoring mentoring2 = mentoringFixture.대기중_멘토링(mentor2.getId(), menteeUser1.getId()); + mentoring1.checkByMentee(); + mentoringRepository.save(mentoring1); + + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentee( + menteeUser1.getId(), VerifyStatus.PENDING, pageable); + + // then + assertThat(response.content()) + .extracting(MentoringForMenteeResponse::mentoringId, MentoringForMenteeResponse::isChecked) + .containsExactlyInAnyOrder( + tuple(mentoring1.getId(), true), + tuple(mentoring2.getId(), false) + ); + } + + @Test + void 멘토링이_없는_경우_빈_리스트를_반환한다() { + // when + SliceResponse response = mentoringQueryService.getMentoringsForMentee( + mentorUser1.getId(), VerifyStatus.APPROVED, pageable); + + // then + assertThat(response.content()).isEmpty(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/news/fixture/LikedNewsFixture.java b/src/test/java/com/example/solidconnection/news/fixture/LikedNewsFixture.java new file mode 100644 index 000000000..acacb25b0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/LikedNewsFixture.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.news.fixture; + +import com.example.solidconnection.news.domain.LikedNews; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class LikedNewsFixture { + + private final LikedNewsFixtureBuilder likedNewsFixtureBuilder; + + public LikedNews 소식지_좋아요(long newsId, long siteUserId) { + return likedNewsFixtureBuilder.likedNews() + .newsId(newsId) + .siteUserId(siteUserId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/news/fixture/LikedNewsFixtureBuilder.java b/src/test/java/com/example/solidconnection/news/fixture/LikedNewsFixtureBuilder.java new file mode 100644 index 000000000..8554dc6b9 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/LikedNewsFixtureBuilder.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.news.fixture; + +import com.example.solidconnection.news.domain.LikedNews; +import com.example.solidconnection.news.repository.LikedNewsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class LikedNewsFixtureBuilder { + + private final LikedNewsRepository likedNewsRepository; + + private long newsId; + + private long siteUserId; + + public LikedNewsFixtureBuilder likedNews() { + return new LikedNewsFixtureBuilder(likedNewsRepository); + } + + public LikedNewsFixtureBuilder newsId(long newsId) { + this.newsId = newsId; + return this; + } + + public LikedNewsFixtureBuilder siteUserId(long siteUserId) { + this.siteUserId = siteUserId; + return this; + } + + public LikedNews create() { + LikedNews likedNews = new LikedNews(newsId, siteUserId); + return likedNewsRepository.save(likedNews); + } +} diff --git a/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java new file mode 100644 index 000000000..44091a51b --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.news.fixture; + +import com.example.solidconnection.news.domain.News; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class NewsFixture { + + private final NewsFixtureBuilder newsFixtureBuilder; + + public News 소식지(long siteUserId) { + return newsFixtureBuilder + .title("소식지 제목") + .description("소식지 설명") + .thumbnailUrl("news/5a02ba2f-38f5-4ae9-9a24-53d624a18233") + .url("https://youtu.be/test") + .siteUserId(siteUserId) + .create(); + } + + public News 소식지(long siteUserId, String thumbnailUrl) { + return newsFixtureBuilder + .title("소식지 제목") + .description("소식지 설명") + .thumbnailUrl(thumbnailUrl) + .url("https://youtu.be/test") + .siteUserId(siteUserId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java b/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java new file mode 100644 index 000000000..5da97d93f --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.news.fixture; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.repository.NewsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class NewsFixtureBuilder { + + private final NewsRepository newsRepository; + + private String title; + private String description; + private String thumbnailUrl; + private String url; + private long siteUserId; + + public NewsFixtureBuilder title(String title) { + this.title = title; + return this; + } + + public NewsFixtureBuilder description(String description) { + this.description = description; + return this; + } + + public NewsFixtureBuilder thumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + return this; + } + + public NewsFixtureBuilder url(String url) { + this.url = url; + return this; + } + + public NewsFixtureBuilder siteUserId(long siteUserId) { + this.siteUserId = siteUserId; + return this; + } + + public News create() { + News news = new News( + title, + description, + thumbnailUrl, + url, + siteUserId); + return newsRepository.save(news); + } +} diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java new file mode 100644 index 000000000..f82a3bd84 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -0,0 +1,315 @@ +package com.example.solidconnection.news.service; + +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_NEWS_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.then; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.config.NewsProperties; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsUpdateRequest; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.news.repository.NewsRepository; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@TestContainerSpringBootTest +@DisplayName("소식지 생성/수정/삭제 서비스 테스트") +class NewsCommandServiceTest { + + @Autowired + private NewsCommandService newsCommandService; + + @Autowired + private NewsProperties newsProperties; + + @MockBean + private S3Service s3Service; + + @Autowired + private NewsRepository newsRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private NewsFixture newsFixture; + + private SiteUser user; + + @BeforeEach + void setUp() { + user = siteUserFixture.멘토(1, "mentor"); + } + + @Nested + class 소식지_생성_테스트 { + + @Test + void 소식지를_성공적으로_생성한다() { + // given + NewsCreateRequest request = createNewsCreateRequest(); + MultipartFile imageFile = createImageFile(); + String expectedImageUrl = "news/5a02ba2f-38f5-4ae9-9a24-53d624a18233"; + given(s3Service.uploadFile(any(), eq(ImgType.NEWS))) + .willReturn(new UploadedFileUrlResponse(expectedImageUrl)); + + // when + NewsCommandResponse response = newsCommandService.createNews(user.getId(), request, imageFile); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertThat(response.id()).isEqualTo(savedNews.getId()); + } + } + + private NewsCreateRequest createNewsCreateRequest() { + return new NewsCreateRequest("제목", "설명", "https://youtu.be/test"); + } + + @Nested + class 소식지_수정_테스트 { + + private static final String CUSTOM_IMAGE_URL = "news/custom-image-url"; + + private News originNews; + + @Nested + class 기본_필드_수정_테스트 { + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(user.getId()); + } + + @Test + void 소식지를_성공적으로_수정한다() { + // given + String expectedTitle = "제목 수정"; + String expectedDescription = "설명 수정"; + String expectedUrl = "https://youtu.be/test-edit"; + MultipartFile expectedFile = createImageFile(); + String expectedNewImageUrl = "news/5a02ba2f-38f5-4ae9-9a24-53d624a18233-edit"; + given(s3Service.uploadFile(any(), eq(ImgType.NEWS))) + .willReturn(new UploadedFileUrlResponse(expectedNewImageUrl)); + NewsUpdateRequest request = createNewsUpdateRequest( + expectedTitle, + expectedDescription, + expectedUrl, + null); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + expectedFile); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getTitle()).isEqualTo(expectedTitle), + () -> assertThat(savedNews.getDescription()).isEqualTo(expectedDescription), + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(expectedNewImageUrl), + () -> assertThat(savedNews.getUrl()).isEqualTo(expectedUrl) + ); + } + + @Test + void 다른_사용자의_소식지를_수정하면_예외가_발생한다() { + // given + SiteUser anotherUser = siteUserFixture.멘토(2, "anotherMentor"); + NewsUpdateRequest request = createNewsUpdateRequest( + "제목 수정", + null, + null, + null); + + // when & then + assertThatCode(() -> newsCommandService.updateNews( + anotherUser.getId(), + originNews.getId(), + request, + null)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_NEWS_ACCESS.getMessage()); + } + } + + @Nested + class 커스텀_이미지_관련_수정_테스트 { + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(user.getId(), CUSTOM_IMAGE_URL); + } + + @Test + void 기본_이미지로_변경_요청시_기존_커스텀_이미지를_삭제하고_기본_이미지로_변경한다() { + // given + NewsUpdateRequest request = createNewsUpdateRequest( + null, + null, + null, + true); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + null); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL), + () -> then(s3Service).should(never()).uploadFile(null, ImgType.NEWS) + ); + } + + @Test + void 새_이미지_업로드시_기존_커스텀_이미지를_삭제하고_새_이미지로_변경한다() { + // given + MultipartFile newImageFile = createImageFile(); + String newImageUrl = "news/new-image-url"; + given(s3Service.uploadFile(newImageFile, ImgType.NEWS)) + .willReturn(new UploadedFileUrlResponse(newImageUrl)); + NewsUpdateRequest request = createNewsUpdateRequest( + null, + null, + null, + null); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + newImageFile); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl), + () -> then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL), + () -> then(s3Service).should().uploadFile(any(), any()) + ); + } + } + + @Nested + class 기본_이미지_관련_수정_테스트 { + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(user.getId(), newsProperties.defaultThumbnailUrl()); + } + + @Test + void 기본_이미지에서_기본_이미지로_변경_요청시_삭제_호출되지_않는다() { + // given + NewsUpdateRequest request = createNewsUpdateRequest( + null, + null, + null, + true); + + // when + newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + null); + + // then + News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should(never()).uploadFile(null, ImgType.NEWS) + ); + } + + @Test + void 기본_이미지에서_새_이미지_업로드시_삭제_호출되지_않고_새_이미지로_변경한다() { + // given + MultipartFile newImageFile = createImageFile(); + String newImageUrl = "news/new-image-url"; + given(s3Service.uploadFile(newImageFile, ImgType.NEWS)) + .willReturn(new UploadedFileUrlResponse(newImageUrl)); + NewsUpdateRequest request = createNewsUpdateRequest(null, null, null, null); + + // when + newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + newImageFile); + + // then + News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl), + () -> then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should().uploadFile(any(), any()) + ); + } + } + } + + private NewsUpdateRequest createNewsUpdateRequest(String title, String description, String url, Boolean resetToDefaultImage) { + return new NewsUpdateRequest(title, description, url, resetToDefaultImage); + } + + private MockMultipartFile createImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + @Nested + class 소식지_삭제_테스트 { + + @Test + void 소식지를_성공적으로_삭제한다() { + // given + News originNews = newsFixture.소식지(user.getId()); + String expectedImageUrl = originNews.getThumbnailUrl(); + + // when + NewsCommandResponse response = newsCommandService.deleteNewsById(user.getId(), originNews.getId()); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(originNews.getId()), + () -> assertThat(newsRepository.findById(originNews.getId())).isEmpty(), + () -> then(s3Service).should().deletePostImage(expectedImageUrl) + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/news/service/NewsLikeServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsLikeServiceTest.java new file mode 100644 index 000000000..2600c2891 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsLikeServiceTest.java @@ -0,0 +1,93 @@ +package com.example.solidconnection.news.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_LIKED_NEWS; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_LIKED_NEWS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.news.repository.LikedNewsRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("소식지 좋아요 서비스 테스트") +class NewsLikeServiceTest { + + @Autowired + private NewsLikeService newsLikeService; + + @Autowired + private LikedNewsRepository likedNewsRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private NewsFixture newsFixture; + + private SiteUser user; + private News news; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + news = newsFixture.소식지(siteUserFixture.멘토(1, "mentor").getId()); + } + + @Nested + class 소식지_좋아요를_등록한다 { + + @Test + void 성공적으로_좋아요를_등록한다() { + // when + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // then + assertThat(likedNewsRepository.existsByNewsIdAndSiteUserId(news.getId(), user.getId())).isTrue(); + } + + @Test + void 이미_좋아요했으면_예외가_발생한다() { + // given + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // when & then + assertThatCode(() -> newsLikeService.addNewsLike(user.getId(), news.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_LIKED_NEWS.getMessage()); + } + } + + @Nested + class 소식지_좋아요를_취소한다 { + + @Test + void 성공적으로_좋아요를_취소한다() { + // given + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // when + newsLikeService.cancelNewsLike(user.getId(), news.getId()); + + // then + assertThat(likedNewsRepository.existsByNewsIdAndSiteUserId(news.getId(), user.getId())).isFalse(); + } + + @Test + void 좋아요하지_않았으면_예외가_발생한다() { + // when & then + assertThatCode(() -> newsLikeService.cancelNewsLike(user.getId(), news.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_LIKED_NEWS.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java new file mode 100644 index 000000000..6c69db0cf --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -0,0 +1,101 @@ +package com.example.solidconnection.news.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsListResponse; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.fixture.LikedNewsFixture; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("소식지 조회 서비스 테스트") +class NewsQueryServiceTest { + + @Autowired + private NewsQueryService newsQueryService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private NewsFixture newsFixture; + + @Autowired + private LikedNewsFixture likedNewsFixture; + + @Test + void 로그인하지_않은_사용자가_특정_사용자의_소식지_목록을_성공적으로_조회한다() { + // given + SiteUser author = siteUserFixture.멘토(1, "author"); + SiteUser otherUser = siteUserFixture.멘토(2, "other"); + + News news1 = newsFixture.소식지(author.getId()); + News news2 = newsFixture.소식지(author.getId()); + newsFixture.소식지(otherUser.getId()); + List newsList = List.of(news1, news2); + + // when + NewsListResponse response = newsQueryService.findNewsByAuthorId(null, author.getId()); + + // then + assertAll( + () -> assertThat(response.newsResponseList()) + .extracting(NewsResponse::id) + .containsExactlyInAnyOrder(news1.getId(), news2.getId()), + () -> assertThat(response.newsResponseList()) + .extracting(NewsResponse::updatedAt) + .isSortedAccordingTo(Comparator.reverseOrder()), + () -> assertThat(response.newsResponseList()) + .extracting(NewsResponse::isLiked) + .containsOnly((Boolean) null) + ); + } + + @Test + void 로그인한_사용자가_특정_사용자의_소식지_목록을_성공적으로_조회한다() { + // given + SiteUser author = siteUserFixture.멘토(1, "author"); + SiteUser loginUser = siteUserFixture.멘토(2, "loginUser"); + + News news1 = newsFixture.소식지(author.getId()); + News news2 = newsFixture.소식지(author.getId()); + News news3 = newsFixture.소식지(author.getId()); + + likedNewsFixture.소식지_좋아요(news1.getId(), loginUser.getId()); + likedNewsFixture.소식지_좋아요(news3.getId(), loginUser.getId()); + + List newsList = List.of(news1, news2, news3); + + // when + NewsListResponse response = newsQueryService.findNewsByAuthorId(loginUser.getId(), author.getId()); + + // then + assertAll( + () -> assertThat(response.newsResponseList()) + .extracting(NewsResponse::id) + .containsExactlyInAnyOrder(news1.getId(), news2.getId(), news3.getId()), + () -> assertThat(response.newsResponseList()) + .extracting(NewsResponse::updatedAt) + .isSortedAccordingTo(Comparator.reverseOrder()), + () -> { + Map likeStatusMap = response.newsResponseList().stream() + .collect(Collectors.toMap(NewsResponse::id, NewsResponse::isLiked)); + assertThat(likeStatusMap.get(news1.getId())).isTrue(); + assertThat(likeStatusMap.get(news2.getId())).isFalse(); + assertThat(likeStatusMap.get(news3.getId())).isTrue(); + } + ); + } +} diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java new file mode 100644 index 000000000..91c837bf3 --- /dev/null +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.report.fixture; + +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.TargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ReportFixture { + + private final ReportFixtureBuilder reportFixtureBuilder; + + public Report 신고(long reporterId, TargetType targetType, long targetId) { + return reportFixtureBuilder.report() + .reporterId(reporterId) + .targetType(targetType) + .targetId(targetId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java new file mode 100644 index 000000000..08d0b276c --- /dev/null +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.report.fixture; + +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.repository.ReportRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ReportFixtureBuilder { + + private final ReportRepository reportRepository; + + private long reporterId; + private TargetType targetType; + private long targetId; + private ReportType reportType = ReportType.ADVERTISEMENT; + + public ReportFixtureBuilder report() { + return new ReportFixtureBuilder(reportRepository); + } + + public ReportFixtureBuilder reporterId(long reporterId) { + this.reporterId = reporterId; + return this; + } + + public ReportFixtureBuilder targetType(TargetType targetType) { + this.targetType = targetType; + return this; + } + + public ReportFixtureBuilder targetId(long targetId) { + this.targetId = targetId; + return this; + } + + public ReportFixtureBuilder reasonType(ReportType reportType) { + this.reportType = reportType; + return this; + } + + public Report create() { + Report report = new Report( + reporterId, + reportType, + targetType, + targetId + ); + return reportRepository.save(report); + } +} diff --git a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java new file mode 100644 index 000000000..23523ae34 --- /dev/null +++ b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.report.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.dto.ReportRequest; +import com.example.solidconnection.report.fixture.ReportFixture; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("신고 서비스 테스트") +@TestContainerSpringBootTest +class ReportServiceTest { + + @Autowired + private ReportService reportService; + + @Autowired + private ReportRepository reportRepository; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private ReportFixture reportFixture; + + private SiteUser siteUser; + private Post post; + + @BeforeEach + void setUp() { + siteUser = siteUserFixture.사용자(); + Board board = boardFixture.자유게시판(); + post = postFixture.게시글(board, siteUser); + } + + @Nested + class 신고_생성 { + + @Test + void 정상적으로_신고한다() { + // given + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, post.getId()); + + // when + reportService.createReport(siteUser.getId(), request); + + // then + boolean isSaved = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + siteUser.getId(), TargetType.POST, post.getId()); + assertThat(isSaved).isTrue(); + } + + @Test + void 신고_대상이_존재하지_않으면_예외가_발생한다() { + // given + long notExistingId = 999L; + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, notExistingId); + + // when & then + assertThatCode(() -> reportService.createReport(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + } + + @Test + void 이미_신고한_경우_예외가_발생한다() { + // given + reportFixture.신고(siteUser.getId(), TargetType.POST, post.getId()); + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, post.getId()); + + // when & then + assertThatCode(() -> reportService.createReport(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/score/fixture/GpaScoreFixture.java b/src/test/java/com/example/solidconnection/score/fixture/GpaScoreFixture.java new file mode 100644 index 000000000..256e5c720 --- /dev/null +++ b/src/test/java/com/example/solidconnection/score/fixture/GpaScoreFixture.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.score.fixture; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class GpaScoreFixture { + + private final GpaScoreFixtureBuilder gpaScoreFixtureBuilder; + + public GpaScore GPA_점수(VerifyStatus verifyStatus, SiteUser siteUser) { + return gpaScoreFixtureBuilder.gpaScore() + .gpa(new Gpa(4.0, 4.5, "/gpa-report.pdf")) + .verifyStatus(verifyStatus) + .siteUser(siteUser) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/score/fixture/GpaScoreFixtureBuilder.java b/src/test/java/com/example/solidconnection/score/fixture/GpaScoreFixtureBuilder.java new file mode 100644 index 000000000..f95f31e2c --- /dev/null +++ b/src/test/java/com/example/solidconnection/score/fixture/GpaScoreFixtureBuilder.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.score.fixture; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class GpaScoreFixtureBuilder { + + private final GpaScoreRepository gpaScoreRepository; + + private Gpa gpa; + private VerifyStatus verifyStatus; + private SiteUser siteUser; + + public GpaScoreFixtureBuilder gpaScore() { + return new GpaScoreFixtureBuilder(gpaScoreRepository); + } + + public GpaScoreFixtureBuilder gpa(Gpa gpa) { + this.gpa = gpa; + return this; + } + + public GpaScoreFixtureBuilder verifyStatus(VerifyStatus verifyStatus) { + this.verifyStatus = verifyStatus; + return this; + } + + public GpaScoreFixtureBuilder siteUser(SiteUser siteUser) { + this.siteUser = siteUser; + return this; + } + + public GpaScore create() { + GpaScore gpaScore = new GpaScore(gpa, siteUser); + gpaScore.setVerifyStatus(verifyStatus); + return gpaScoreRepository.save(gpaScore); + } +} diff --git a/src/test/java/com/example/solidconnection/score/fixture/LanguageTestScoreFixture.java b/src/test/java/com/example/solidconnection/score/fixture/LanguageTestScoreFixture.java new file mode 100644 index 000000000..ce146a21c --- /dev/null +++ b/src/test/java/com/example/solidconnection/score/fixture/LanguageTestScoreFixture.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.score.fixture; + +import static com.example.solidconnection.university.domain.LanguageTestType.TOEIC; + +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class LanguageTestScoreFixture { + + private final LanguageTestScoreFixtureBuilder languageTestScoreFixtureBuilder; + + public LanguageTestScore 어학_점수(VerifyStatus verifyStatus, SiteUser siteUser) { + return languageTestScoreFixtureBuilder.languageTestScore() + .languageTest(new LanguageTest(TOEIC, "500", "/language-report.pdf")) + .verifyStatus(verifyStatus) + .siteUser(siteUser) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/score/fixture/LanguageTestScoreFixtureBuilder.java b/src/test/java/com/example/solidconnection/score/fixture/LanguageTestScoreFixtureBuilder.java new file mode 100644 index 000000000..aa7c4bdf5 --- /dev/null +++ b/src/test/java/com/example/solidconnection/score/fixture/LanguageTestScoreFixtureBuilder.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.score.fixture; + +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class LanguageTestScoreFixtureBuilder { + + private final LanguageTestScoreRepository languageTestScoreRepository; + + private LanguageTest languageTest; + private VerifyStatus verifyStatus; + private SiteUser siteUser; + + public LanguageTestScoreFixtureBuilder languageTestScore() { + return new LanguageTestScoreFixtureBuilder(languageTestScoreRepository); + } + + public LanguageTestScoreFixtureBuilder languageTest(LanguageTest languageTest) { + this.languageTest = languageTest; + return this; + } + + public LanguageTestScoreFixtureBuilder verifyStatus(VerifyStatus verifyStatus) { + this.verifyStatus = verifyStatus; + return this; + } + + public LanguageTestScoreFixtureBuilder siteUser(SiteUser siteUser) { + this.siteUser = siteUser; + return this; + } + + public LanguageTestScore create() { + LanguageTestScore languageTestScore = new LanguageTestScore(languageTest, siteUser); + languageTestScore.setVerifyStatus(verifyStatus); + return languageTestScoreRepository.save(languageTestScore); + } +} diff --git a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java index a8b1ac3d8..8760a645b 100644 --- a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java @@ -1,41 +1,37 @@ package com.example.solidconnection.score.service; -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.dto.GpaScoreRequest; -import com.example.solidconnection.score.dto.GpaScoreStatusResponse; import com.example.solidconnection.score.dto.GpaScoreStatusesResponse; import com.example.solidconnection.score.dto.LanguageTestScoreRequest; -import com.example.solidconnection.score.dto.LanguageTestScoreStatusResponse; import com.example.solidconnection.score.dto.LanguageTestScoreStatusesResponse; +import com.example.solidconnection.score.fixture.GpaScoreFixture; +import com.example.solidconnection.score.fixture.LanguageTestScoreFixture; import com.example.solidconnection.score.repository.GpaScoreRepository; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.LanguageTestType; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.BDDMockito.given; - +@TestContainerSpringBootTest @DisplayName("점수 서비스 테스트") -class ScoreServiceTest extends BaseIntegrationTest { +class ScoreServiceTest { @Autowired private ScoreService scoreService; @@ -43,44 +39,47 @@ class ScoreServiceTest extends BaseIntegrationTest { @Autowired private GpaScoreRepository gpaScoreRepository; - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired private LanguageTestScoreRepository languageTestScoreRepository; @MockBean private S3Service s3Service; + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private GpaScoreFixture gpaScoreFixture; + + @Autowired + private LanguageTestScoreFixture languageTestScoreFixture; + + private SiteUser user; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + } + @Test void GPA_점수_상태를_조회한다() { // given - SiteUser testUser = createSiteUser(); List scores = List.of( - createGpaScore(testUser, 3.5, 4.5), - createGpaScore(testUser, 3.8, 4.5) + gpaScoreFixture.GPA_점수(VerifyStatus.PENDING, user), + gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user) ); // when - GpaScoreStatusesResponse response = scoreService.getGpaScoreStatus(testUser); + GpaScoreStatusesResponse response = scoreService.getGpaScoreStatus(user.getId()); // then - assertThat(response.gpaScoreStatusResponseList()) - .hasSize(scores.size()) - .containsExactlyInAnyOrder( - scores.stream() - .map(GpaScoreStatusResponse::from) - .toArray(GpaScoreStatusResponse[]::new) - ); + assertThat(response.gpaScoreStatusResponseList()).hasSize(scores.size()); } @Test void GPA_점수가_없는_경우_빈_리스트를_반환한다() { - // given - SiteUser testUser = createSiteUser(); - // when - GpaScoreStatusesResponse response = scoreService.getGpaScoreStatus(testUser); + GpaScoreStatusesResponse response = scoreService.getGpaScoreStatus(user.getId()); // then assertThat(response.gpaScoreStatusResponseList()).isEmpty(); @@ -89,33 +88,22 @@ class ScoreServiceTest extends BaseIntegrationTest { @Test void 어학_시험_점수_상태를_조회한다() { // given - SiteUser testUser = createSiteUser(); List scores = List.of( - createLanguageTestScore(testUser, LanguageTestType.TOEIC, "100"), - createLanguageTestScore(testUser, LanguageTestType.TOEFL_IBT, "7.5") + languageTestScoreFixture.어학_점수(VerifyStatus.PENDING, user), + languageTestScoreFixture.어학_점수(VerifyStatus.PENDING, user) ); - siteUserRepository.save(testUser); // when - LanguageTestScoreStatusesResponse response = scoreService.getLanguageTestScoreStatus(testUser); + LanguageTestScoreStatusesResponse response = scoreService.getLanguageTestScoreStatus(user.getId()); // then - assertThat(response.languageTestScoreStatusResponseList()) - .hasSize(scores.size()) - .containsExactlyInAnyOrder( - scores.stream() - .map(LanguageTestScoreStatusResponse::from) - .toArray(LanguageTestScoreStatusResponse[]::new) - ); + assertThat(response.languageTestScoreStatusResponseList()).hasSize(scores.size()); } @Test void 어학_시험_점수가_없는_경우_빈_리스트를_반환한다() { - // given - SiteUser testUser = createSiteUser(); - // when - LanguageTestScoreStatusesResponse response = scoreService.getLanguageTestScoreStatus(testUser); + LanguageTestScoreStatusesResponse response = scoreService.getLanguageTestScoreStatus(user.getId()); // then assertThat(response.languageTestScoreStatusResponseList()).isEmpty(); @@ -124,76 +112,33 @@ class ScoreServiceTest extends BaseIntegrationTest { @Test void GPA_점수를_등록한다() { // given - SiteUser testUser = createSiteUser(); GpaScoreRequest request = createGpaScoreRequest(); MockMultipartFile file = createFile(); String fileUrl = "/gpa-report.pdf"; given(s3Service.uploadFile(file, ImgType.GPA)).willReturn(new UploadedFileUrlResponse(fileUrl)); // when - long scoreId = scoreService.submitGpaScore(testUser, request, file); + long scoreId = scoreService.submitGpaScore(user.getId(), request, file); GpaScore savedScore = gpaScoreRepository.findById(scoreId).orElseThrow(); // then - assertAll( - () -> assertThat(savedScore.getId()).isEqualTo(scoreId), - () -> assertThat(savedScore.getGpa().getGpa()).isEqualTo(request.gpa()), - () -> assertThat(savedScore.getGpa().getGpaCriteria()).isEqualTo(request.gpaCriteria()), - () -> assertThat(savedScore.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING), - () -> assertThat(savedScore.getGpa().getGpaReportUrl()).isEqualTo(fileUrl) - ); + assertThat(savedScore.getId()).isEqualTo(scoreId); } @Test void 어학_시험_점수를_등록한다() { // given - SiteUser testUser = createSiteUser(); LanguageTestScoreRequest request = createLanguageTestScoreRequest(); MockMultipartFile file = createFile(); String fileUrl = "/gpa-report.pdf"; given(s3Service.uploadFile(file, ImgType.LANGUAGE_TEST)).willReturn(new UploadedFileUrlResponse(fileUrl)); // when - long scoreId = scoreService.submitLanguageTestScore(testUser, request, file); + long scoreId = scoreService.submitLanguageTestScore(user.getId(), request, file); LanguageTestScore savedScore = languageTestScoreRepository.findById(scoreId).orElseThrow(); // then - assertAll( - () -> assertThat(savedScore.getId()).isEqualTo(scoreId), - () -> assertThat(savedScore.getLanguageTest().getLanguageTestType()).isEqualTo(request.languageTestType()), - () -> assertThat(savedScore.getLanguageTest().getLanguageTestScore()).isEqualTo(request.languageTestScore()), - () -> assertThat(savedScore.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING), - () -> assertThat(savedScore.getLanguageTest().getLanguageTestReportUrl()).isEqualTo(fileUrl) - ); - } - - private SiteUser createSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } - - private GpaScore createGpaScore(SiteUser siteUser, double gpa, double gpaCriteria) { - GpaScore gpaScore = new GpaScore( - new Gpa(gpa, gpaCriteria, "/gpa-report.pdf"), - siteUser - ); - gpaScore.setSiteUser(siteUser); - return gpaScoreRepository.save(gpaScore); - } - - private LanguageTestScore createLanguageTestScore(SiteUser siteUser, LanguageTestType languageTestType, String score) { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(languageTestType, score, "/gpa-report.pdf"), - siteUser - ); - languageTestScore.setSiteUser(siteUser); - return languageTestScoreRepository.save(languageTestScore); + assertThat(savedScore.getId()).isEqualTo(scoreId); } private GpaScoreRequest createGpaScoreRequest() { diff --git a/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java new file mode 100644 index 000000000..e68c61782 --- /dev/null +++ b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java @@ -0,0 +1,101 @@ +package com.example.solidconnection.security.aspect; + +import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +@TestContainerSpringBootTest +@DisplayName("권한 검사 Aspect 테스트") +class RoleAuthorizationAspectTest { + + @Autowired + private TestService testService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Test + void 요구하는_역할을_가진_사용자는_메서드를_정상적으로_호출할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); + + // when & then + assertAll( + () -> assertThatCode(() -> testService.adminOnlyMethod(admin.getId())) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.mentorOrAdminMethod(mentor.getId())) + .doesNotThrowAnyException() + ); + } + + @Test + void 요구하는_역할이_없는_사용자가_메서드를_호출하면_예외가_발생한다() { + // given + SiteUser user = siteUserFixture.사용자(); + + // when & then + assertThatCode(() -> testService.mentorOrAdminMethod(user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ACCESS_DENIED.getMessage()); + } + + @Test + void 역할을_요구하지_않는_메서드는_누구나_호출할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); + SiteUser user = siteUserFixture.사용자(); + + // when & then + assertAll( + () -> assertThatCode(() -> testService.publicMethod(admin.getId())) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.publicMethod(mentor.getId())) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.publicMethod(user.getId())) + .doesNotThrowAnyException() + ); + } + + @TestConfiguration + static class TestConfig { + + @Bean + public TestService testService() { + return new TestService(); + } + } + + @Component + static class TestService { + + @RequireRoleAccess(roles = {Role.ADMIN}) + public boolean adminOnlyMethod(@AuthorizedUser long siteUserId) { + return true; + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + public boolean mentorOrAdminMethod(@AuthorizedUser long siteUserId) { + return true; + } + + public boolean publicMethod(@AuthorizedUser long siteUserId) { + return true; + } + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java b/src/test/java/com/example/solidconnection/security/authentication/TokenAuthenticationProviderTest.java similarity index 56% rename from src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java rename to src/test/java/com/example/solidconnection/security/authentication/TokenAuthenticationProviderTest.java index 423bec415..c8715f81a 100644 --- a/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java +++ b/src/test/java/com/example/solidconnection/security/authentication/TokenAuthenticationProviderTest.java @@ -1,16 +1,21 @@ -package com.example.solidconnection.custom.security.provider; +package com.example.solidconnection.security.authentication; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; -import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.userdetails.SiteUserDetails; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import java.net.PasswordAuthentication; +import java.util.Date; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -18,57 +23,47 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; -import java.net.PasswordAuthentication; -import java.util.Date; - -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - @TestContainerSpringBootTest @DisplayName("사용자 인증정보 provider 테스트") -class SiteUserAuthenticationProviderTest { +class TokenAuthenticationProviderTest { @Autowired - private SiteUserAuthenticationProvider siteUserAuthenticationProvider; + private TokenAuthenticationProvider tokenAuthenticationProvider; @Autowired private JwtProperties jwtProperties; @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; - private SiteUser siteUser; + private SiteUser user; @BeforeEach void setUp() { - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); + user = siteUserFixture.사용자(); } @Test void 처리할_수_있는_타입인지를_반환한다() { // given - Class supportedType = SiteUserAuthentication.class; + Class supportedType = TokenAuthentication.class; Class notSupportedType = PasswordAuthentication.class; // when & then assertAll( - () -> assertThat(siteUserAuthenticationProvider.supports(supportedType)).isTrue(), - () -> assertThat(siteUserAuthenticationProvider.supports(notSupportedType)).isFalse() + () -> assertThat(tokenAuthenticationProvider.supports(supportedType)).isTrue(), + () -> assertThat(tokenAuthenticationProvider.supports(notSupportedType)).isFalse() ); } @Test void 유효한_토큰이면_정상적으로_인증_정보를_반환한다() { // given - String token = createValidToken(siteUser.getId()); - SiteUserAuthentication auth = new SiteUserAuthentication(token); + String token = createValidToken(user.getId()); + TokenAuthentication auth = new TokenAuthentication(token); // when - Authentication result = siteUserAuthenticationProvider.authenticate(auth); + Authentication result = tokenAuthenticationProvider.authenticate(auth); // then assertThat(result).isNotNull(); @@ -79,39 +74,40 @@ void setUp() { } @Nested - class 예외_응답을_반환하다 { + class 예외가_발생한다 { @Test - void 유효하지_않은_토큰이면_예외_응답을_반환한다() { + void 유효하지_않은_토큰이면_예외가_발생한다() { // given - SiteUserAuthentication expiredAuth = new SiteUserAuthentication(createExpiredToken()); + TokenAuthentication expiredAuth = new TokenAuthentication(createExpiredToken()); // when & then - assertThatCode(() -> siteUserAuthenticationProvider.authenticate(expiredAuth)) + assertThatCode(() -> tokenAuthenticationProvider.authenticate(expiredAuth)) .isInstanceOf(CustomException.class) .hasMessageContaining(INVALID_TOKEN.getMessage()); } @Test - void 사용자_정보의_형식이_다르면_예외_응답을_반환한다() { + void 사용자_정보의_형식이_다르면_예외가_발생한다() { // given - SiteUserAuthentication wrongSubjectTypeAuth = new SiteUserAuthentication(createWrongSubjectTypeToken()); + TokenAuthentication wrongSubjectTypeAuth = new TokenAuthentication( + createWrongSubjectTypeToken()); // when & then - assertThatCode(() -> siteUserAuthenticationProvider.authenticate(wrongSubjectTypeAuth)) + assertThatCode(() -> tokenAuthenticationProvider.authenticate(wrongSubjectTypeAuth)) .isInstanceOf(CustomException.class) .hasMessageContaining(INVALID_TOKEN.getMessage()); } @Test - void 유효한_토큰이지만_해당되는_사용자가_없으면_예외_응답을_반환한다() { + void 유효한_토큰이지만_해당되는_사용자가_없으면_예외가_발생한다() { // given - long notExistingUserId = siteUser.getId() + 100; + long notExistingUserId = user.getId() + 100; String token = createValidToken(notExistingUserId); - SiteUserAuthentication auth = new SiteUserAuthentication(token); + TokenAuthentication auth = new TokenAuthentication(token); // when & then - assertThatCode(() -> siteUserAuthenticationProvider.authenticate(auth)) + assertThatCode(() -> tokenAuthenticationProvider.authenticate(auth)) .isInstanceOf(CustomException.class) .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); } @@ -128,7 +124,7 @@ private String createValidToken(long id) { private String createExpiredToken() { return Jwts.builder() - .setSubject(String.valueOf(siteUser.getId())) + .setSubject(String.valueOf(user.getId())) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() - 1000)) .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) @@ -143,14 +139,4 @@ private String createWrongSubjectTypeToken() { .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) .compact(); } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - } } diff --git a/src/test/java/com/example/solidconnection/security/authentication/TokenAuthenticationTest.java b/src/test/java/com/example/solidconnection/security/authentication/TokenAuthenticationTest.java new file mode 100644 index 000000000..90e76abc2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/security/authentication/TokenAuthenticationTest.java @@ -0,0 +1,81 @@ +package com.example.solidconnection.security.authentication; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.ExchangeStatus; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("토큰 인증 정보 테스트") +class TokenAuthenticationTest { + + @Nested + class Authentication의_인증_정보를_반환한다 { + + @Test + void 토큰을_반환한다() { + // given + String token = "token"; + TokenAuthentication authentication = new TokenAuthentication(token); + + // when + String result = authentication.getToken(); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + void 사용자_정보를_반환한다() { + // given + SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); + TokenAuthentication authentication = new TokenAuthentication("token", userDetails); + + // when & then + SiteUserDetails actual = (SiteUserDetails) authentication.getPrincipal(); + + // then + assertThat(actual) + .extracting("siteUser") + .extracting("id") + .isEqualTo(userDetails.getSiteUser().getId()); + } + } + + @Nested + class Authentication의_인증_상태를_반환한다 { + + @Test + void 증명_수단만_포함하여_생성하면_미인증_상태이다() { + // given + TokenAuthentication authentication = new TokenAuthentication("token"); + + // when & then + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + void 사용자_정보와_함께_생성하면_인증된_상태이다() { + // given + SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); + TokenAuthentication authentication = new TokenAuthentication("token", userDetails); + + // when & then + assertThat(authentication.isAuthenticated()).isTrue(); + } + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + ExchangeStatus.CONSIDERING, + Role.MENTEE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java b/src/test/java/com/example/solidconnection/security/filter/ExceptionHandlerFilterTest.java similarity index 90% rename from src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java rename to src/test/java/com/example/solidconnection/security/filter/ExceptionHandlerFilterTest.java index f1b3c7359..aae969160 100644 --- a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java +++ b/src/test/java/com/example/solidconnection/security/filter/ExceptionHandlerFilterTest.java @@ -1,11 +1,19 @@ -package com.example.solidconnection.custom.security.filter; +package com.example.solidconnection.security.filter; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.support.TestContainerSpringBootTest; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -13,22 +21,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.then; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.BDDMockito.willThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; - @TestContainerSpringBootTest class ExceptionHandlerFilterTest { @@ -74,7 +72,7 @@ void setUp() { @ParameterizedTest @MethodSource("provideException") - void 필터_체인에서_예외가_발생하면_예외_응답을_반환한다(Throwable throwable) throws Exception { + void 필터_체인에서_예외가_발생하면_예외가_발생한다(Throwable throwable) throws Exception { // given willThrow(throwable).given(filterChain).doFilter(request, response); diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java b/src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java similarity index 81% rename from src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java rename to src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java index a11d8d28a..3d78f1307 100644 --- a/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java +++ b/src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java @@ -1,13 +1,21 @@ -package com.example.solidconnection.custom.security.filter; +package com.example.solidconnection.security.filter; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; +import static com.example.solidconnection.common.exception.ErrorCode.USER_ALREADY_SIGN_OUT; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.support.TestContainerSpringBootTest; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Date; +import java.util.Objects; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,15 +24,6 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import java.util.Date; -import java.util.Objects; - -import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.spy; - @TestContainerSpringBootTest @DisplayName("로그아웃 체크 필터 테스트") class SignOutCheckFilterTest { @@ -55,12 +54,12 @@ void setUp() { } @Test - void 로그아웃한_토큰이면_예외를_응답한다() throws Exception { + void 로그아웃한_토큰이면_예외가_발생한다() { // given String token = createToken(subject); request = createRequest(token); String refreshTokenKey = BLACKLIST.addPrefix(token); - redisTemplate.opsForValue().set(refreshTokenKey, "signOut"); + redisTemplate.opsForValue().set(refreshTokenKey, token); // when & then assertThatCode(() -> signOutCheckFilter.doFilterInternal(request, response, filterChain)) @@ -95,12 +94,12 @@ void setUp() { } private String createToken(String subject) { - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) - .compact(); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); } private HttpServletRequest createRequest(String token) { diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java similarity index 52% rename from src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java rename to src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java index cbca9c5f2..36d8c3dd8 100644 --- a/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java @@ -1,18 +1,21 @@ -package com.example.solidconnection.custom.security.filter; +package com.example.solidconnection.security.filter; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; -import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; -import com.example.solidconnection.custom.security.userdetails.SiteUserDetailsService; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetailsService; import com.example.solidconnection.support.TestContainerSpringBootTest; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Date; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; @@ -20,23 +23,17 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContextHolder; -import java.util.Date; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.spy; - @TestContainerSpringBootTest @DisplayName("토큰 인증 필터 테스트") -class JwtAuthenticationFilterTest { +class TokenAuthenticationFilterTest { @Autowired - private JwtAuthenticationFilter jwtAuthenticationFilter; + private TokenAuthenticationFilter tokenAuthenticationFilter; @Autowired private JwtProperties jwtProperties; - @MockBean + @MockBean // 이 테스트코드에서 사용자를 조회할 필요는 없으므로 MockBean 으로 대체 private SiteUserDetailsService siteUserDetailsService; private HttpServletRequest request; @@ -56,47 +53,27 @@ void setUp() { request = new MockHttpServletRequest(); // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + tokenAuthenticationFilter.doFilterInternal(request, response, filterChain); // then assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); then(filterChain).should().doFilter(request, response); } - @Nested - class 토큰이_있으면_컨텍스트에_저장한다 { - - @Test - void 유효한_토큰을_컨텍스트에_저장한다() throws Exception { - // given - Date validExpiration = new Date(System.currentTimeMillis() + 1000); - String token = createTokenWithExpiration(validExpiration); - request = createRequestWithToken(token); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(SecurityContextHolder.getContext().getAuthentication()) - .isExactlyInstanceOf(SiteUserAuthentication.class); - then(filterChain).should().doFilter(request, response); - } - - @Test - void 만료된_토큰을_컨텍스트에_저장한다() throws Exception { - // given - Date invalidExpiration = new Date(System.currentTimeMillis() - 1000); - String token = createTokenWithExpiration(invalidExpiration); - request = createRequestWithToken(token); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(SecurityContextHolder.getContext().getAuthentication()) - .isExactlyInstanceOf(ExpiredTokenAuthentication.class); - then(filterChain).should().doFilter(request, response); - } + @Test + void 토큰이_있으면_컨텍스트에_저장한다() throws Exception { + // given + Date validExpiration = new Date(System.currentTimeMillis() + 1000); + String token = createTokenWithExpiration(validExpiration); + request = createRequestWithToken(token); + + // when + tokenAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isExactlyInstanceOf(TokenAuthentication.class); + then(filterChain).should().doFilter(request, response); } private String createTokenWithExpiration(Date expiration) { diff --git a/src/test/java/com/example/solidconnection/security/infrastructure/AuthorizationHeaderParserTest.java b/src/test/java/com/example/solidconnection/security/infrastructure/AuthorizationHeaderParserTest.java new file mode 100644 index 000000000..66ec3c0fb --- /dev/null +++ b/src/test/java/com/example/solidconnection/security/infrastructure/AuthorizationHeaderParserTest.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.security.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; + +@DisplayName("Authorization 해더 파서 테스트") +@TestContainerSpringBootTest +class AuthorizationHeaderParserTest { + + @Autowired + private AuthorizationHeaderParser authorizationHeaderParser; + + @Nested + class 요청으로부터_토큰을_추출한다 { + + @Test + void 지정한_형식의_토큰이_있으면_토큰을_반환한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + String token = "token"; + request.addHeader("Authorization", "Bearer " + token); + + // when + Optional extractedToken = authorizationHeaderParser.parseToken(request); + + // then + assertThat(extractedToken).get().isEqualTo(token); + } + + @Test + void 형식에_맞는_토큰이_없으면_빈_값을_반환한다() { + // given + MockHttpServletRequest noHeader = new MockHttpServletRequest(); + MockHttpServletRequest wrongPrefix = new MockHttpServletRequest(); + wrongPrefix.addHeader("Authorization", "Wrong token"); + MockHttpServletRequest emptyToken = new MockHttpServletRequest(); + emptyToken.addHeader("Authorization", "Bearer "); + + // when & then + assertAll( + () -> assertThat(authorizationHeaderParser.parseToken(noHeader)).isEmpty(), + () -> assertThat(authorizationHeaderParser.parseToken(wrongPrefix)).isEmpty(), + () -> assertThat(authorizationHeaderParser.parseToken(emptyToken)).isEmpty() + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java b/src/test/java/com/example/solidconnection/security/userdetails/SiteUserDetailsServiceTest.java similarity index 68% rename from src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java rename to src/test/java/com/example/solidconnection/security/userdetails/SiteUserDetailsServiceTest.java index 0b2a0db73..d82e7406b 100644 --- a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java +++ b/src/test/java/com/example/solidconnection/security/userdetails/SiteUserDetailsServiceTest.java @@ -1,24 +1,22 @@ -package com.example.solidconnection.custom.security.userdetails; +package com.example.solidconnection.security.userdetails; -import com.example.solidconnection.custom.exception.CustomException; +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; +import java.time.LocalDate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import java.time.LocalDate; - -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - @DisplayName("사용자 인증 정보 서비스 테스트") @TestContainerSpringBootTest class SiteUserDetailsServiceTest { @@ -26,14 +24,17 @@ class SiteUserDetailsServiceTest { @Autowired private SiteUserDetailsService userDetailsService; + @Autowired + private SiteUserFixture siteUserFixture; + @Autowired private SiteUserRepository siteUserRepository; @Test void 사용자_인증_정보를_반환한다() { // given - SiteUser siteUser = siteUserRepository.save(createSiteUser()); - String username = getUserName(siteUser); + SiteUser user = siteUserFixture.사용자(); + String username = getUserName(user); // when SiteUserDetails userDetails = (SiteUserDetails) userDetailsService.loadUserByUsername(username); @@ -41,15 +42,15 @@ class SiteUserDetailsServiceTest { // then assertAll( () -> assertThat(userDetails.getUsername()).isEqualTo(username), - () -> assertThat(userDetails.getSiteUser()).extracting("id").isEqualTo(siteUser.getId()) + () -> assertThat(userDetails.getSiteUser()).extracting("id").isEqualTo(user.getId()) ); } @Nested - class 예외_응답을_반환한다 { + class 예외가_발생한다 { @Test - void 지정되지_않은_형식의_식별자가_주어지면_예외_응답을_반환한다() { + void 지정되지_않은_형식의_식별자가_주어지면_예외가_발생한다() { // given String username = "notNumber"; @@ -60,7 +61,7 @@ class 예외_응답을_반환한다 { } @Test - void 식별자에_해당하는_사용자가_없으면_예외_응답을_반환한다() { + void 식별자에_해당하는_사용자가_없으면_예외가_발생한다() { // given String username = "1234"; @@ -71,12 +72,12 @@ class 예외_응답을_반환한다 { } @Test - void 탈퇴한_사용자이면_예외_응답을_반환한다() { + void 탈퇴한_사용자이면_예외가_발생한다() { // given - SiteUser siteUser = createSiteUser(); - siteUser.setQuitedAt(LocalDate.now()); - siteUserRepository.save(siteUser); - String username = getUserName(siteUser); + SiteUser user = siteUserFixture.사용자(); + user.setQuitedAt(LocalDate.now()); + siteUserRepository.save(user); + String username = getUserName(user); // when & then assertThatCode(() -> userDetailsService.loadUserByUsername(username)) @@ -85,16 +86,6 @@ class 예외_응답을_반환한다 { } } - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - } - private String getUserName(SiteUser siteUser) { return siteUser.getId().toString(); } diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java b/src/test/java/com/example/solidconnection/security/userdetails/SiteUserDetailsTest.java similarity index 52% rename from src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java rename to src/test/java/com/example/solidconnection/security/userdetails/SiteUserDetailsTest.java index b49b9cf27..5d59e99f9 100644 --- a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java +++ b/src/test/java/com/example/solidconnection/security/userdetails/SiteUserDetailsTest.java @@ -1,31 +1,28 @@ -package com.example.solidconnection.custom.security.userdetails; +package com.example.solidconnection.security.userdetails; + +import static org.assertj.core.api.Assertions.assertThat; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; +import java.util.Collection; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; -import java.util.Collection; - -import static org.assertj.core.api.Assertions.assertThat; - @DisplayName("사용자 인증 정보 테스트") @TestContainerSpringBootTest class SiteUserDetailsTest { @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; @Test void 사용자_권한을_정상적으로_반환한다() { // given - SiteUser siteUser = siteUserRepository.save(createSiteUser()); - SiteUserDetails siteUserDetails = new SiteUserDetails(siteUser); + SiteUser user = siteUserFixture.사용자(); + SiteUserDetails siteUserDetails = new SiteUserDetails(user); // when Collection authorities = siteUserDetails.getAuthorities(); @@ -33,16 +30,6 @@ class SiteUserDetailsTest { // then assertThat(authorities) .extracting("authority") - .containsExactly("ROLE_" + siteUser.getRole().name()); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); + .containsExactly("ROLE_" + user.getRole().name()); } } diff --git a/src/test/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidatorTest.java b/src/test/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidatorTest.java new file mode 100644 index 000000000..c3c69f8fc --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidatorTest.java @@ -0,0 +1,76 @@ +package com.example.solidconnection.siteuser.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CHANGED; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CONFIRMED; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("비밀번호 변경 유효성 검사 테스트") +class PasswordConfirmationValidatorTest { + + private static final String MESSAGE = "message"; + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void 유효한_비밀번호_변경_요청은_검증을_통과한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "newPassword123!", "newPassword123!"); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Nested + class 유효하지_않은_비밀번호_변경_테스트 { + + @Test + void 새로운_비밀번호와_확인_비밀번호가_일치하지_않으면_검증에_실패한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "newPassword123!", "differentPassword123!"); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(PASSWORD_NOT_CONFIRMED.getMessage()); + } + + @Test + void 현재_비밀번호와_새로운_비밀번호가_같으면_검증에_실패한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "currentPassword123", "currentPassword123"); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(PASSWORD_NOT_CHANGED.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java new file mode 100644 index 000000000..9c2eb12bc --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java @@ -0,0 +1,80 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class SiteUserFixture { + + private final SiteUserFixtureBuilder siteUserFixtureBuilder; + + public SiteUser 사용자() { + return siteUserFixtureBuilder.siteUser() + .email("test@example.com") + .authType(AuthType.EMAIL) + .nickname("사용자") + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("password123") + .create(); + } + + public SiteUser 사용자(int index, String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("test" + index + "@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("password123") + .create(); + } + + public SiteUser 사용자(String email, AuthType authType) { + return siteUserFixtureBuilder.siteUser() + .email(email) + .authType(authType) + .nickname("사용자") + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("password123") + .create(); + } + + public SiteUser 사용자(String email, String password) { + return siteUserFixtureBuilder.siteUser() + .email(email) + .authType(AuthType.EMAIL) + .nickname("사용자") + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password(password) + .create(); + } + + public SiteUser 멘토(int index, String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("mentor" + index + "@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTOR) + .password("mentor123") + .create(); + } + + public SiteUser 관리자() { + return siteUserFixtureBuilder.siteUser() + .email("admin@example.com") + .authType(AuthType.EMAIL) + .nickname("관리자") + .profileImageUrl("profileImageUrl") + .role(Role.ADMIN) + .password("admin123") + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java new file mode 100644 index 000000000..901de4d6a --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java @@ -0,0 +1,72 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.ExchangeStatus; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.security.crypto.password.PasswordEncoder; + +@TestComponent +@RequiredArgsConstructor +public class SiteUserFixtureBuilder { + + private final SiteUserRepository siteUserRepository; + private final PasswordEncoder passwordEncoder; + + private String email; + private AuthType authType; + private String nickname; + private String profileImageUrl; + private Role role; + private String password; + + public SiteUserFixtureBuilder siteUser() { + return new SiteUserFixtureBuilder(siteUserRepository, passwordEncoder); + } + + public SiteUserFixtureBuilder email(String email) { + this.email = email; + return this; + } + + public SiteUserFixtureBuilder authType(AuthType authType) { + this.authType = authType; + return this; + } + + public SiteUserFixtureBuilder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public SiteUserFixtureBuilder profileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + return this; + } + + public SiteUserFixtureBuilder role(Role role) { + this.role = role; + return this; + } + + public SiteUserFixtureBuilder password(String password) { + this.password = password; + return this; + } + + public SiteUser create() { + SiteUser siteUser = new SiteUser( + email, + nickname, + profileImageUrl, + ExchangeStatus.CONSIDERING, + role, + authType, + passwordEncoder.encode(password) + ); + return siteUserRepository.save(siteUser); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java index c3d9d240e..cca31fbbe 100644 --- a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java @@ -1,17 +1,17 @@ package com.example.solidconnection.siteuser.repository; +import static org.assertj.core.api.Assertions.assertThatCode; + import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.ExchangeStatus; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.support.TestContainerDataJpaTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; -import static org.assertj.core.api.Assertions.assertThatCode; - @TestContainerDataJpaTest class SiteUserRepositoryTest { @@ -22,10 +22,10 @@ class SiteUserRepositoryTest { class 이메일과_인증_유형이_동일한_사용자는_저장할_수_없다 { @Test - void 이메일과_인증_유형이_동일한_사용자를_저장하면_예외_응답을_반환한다() { + void 이메일과_인증_유형이_동일한_사용자를_저장하면_예외가_발생한다() { // given - SiteUser user1 = createSiteUser("email", AuthType.KAKAO); - SiteUser user2 = createSiteUser("email", AuthType.KAKAO); + SiteUser user1 = createSiteUser("email", "nickname1", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", "nickname2", AuthType.KAKAO); siteUserRepository.save(user1); // when, then @@ -36,8 +36,8 @@ class 이메일과_인증_유형이_동일한_사용자는_저장할_수_없다 @Test void 이메일이_같더라도_인증_유형이_다른_사용자는_정상_저장한다() { // given - SiteUser user1 = createSiteUser("email", AuthType.KAKAO); - SiteUser user2 = createSiteUser("email", AuthType.APPLE); + SiteUser user1 = createSiteUser("email", "nickname1", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", "nickname2", AuthType.APPLE); siteUserRepository.save(user1); // when, then @@ -46,12 +46,44 @@ class 이메일과_인증_유형이_동일한_사용자는_저장할_수_없다 } } - private SiteUser createSiteUser(String email, AuthType authType) { + @Nested + class 닉네임은_중복될_수_없다 { + + @Test + void 중복된_닉네임으로_사용자를_저장하면_예외가_발생한다() { + // given + SiteUser user1 = createSiteUser("email1", "nickname", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email2", "nickname", AuthType.KAKAO); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.saveAndFlush(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 중복된_닉네임으로_변경하면_예외가_발생한다() { + // given + SiteUser user1 = createSiteUser("email1", "nickname1", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email2", "nickname2", AuthType.KAKAO); + siteUserRepository.save(user1); + siteUserRepository.save(user2); + + // when + user2.setNickname("nickname1"); + + // then + assertThatCode(() -> siteUserRepository.saveAndFlush(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + } + + private SiteUser createSiteUser(String email, String nickname, AuthType authType) { return new SiteUser( email, - "nickname", + nickname, "profileImageUrl", - PreparationStatus.CONSIDERING, + ExchangeStatus.CONSIDERING, Role.MENTEE, authType ); diff --git a/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java index 95a887736..d8e5c950f 100644 --- a/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java @@ -1,20 +1,48 @@ package com.example.solidconnection.siteuser.service; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; +import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH; +import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; +import static com.example.solidconnection.siteuser.service.MyPageService.NICKNAME_LAST_CHANGE_DATE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.then; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.country.domain.InterestedCountry; +import com.example.solidconnection.location.country.fixture.CountryFixture; +import com.example.solidconnection.location.country.repository.InterestedCountryRepository; +import com.example.solidconnection.location.region.domain.InterestedRegion; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.fixture.RegionFixture; +import com.example.solidconnection.location.region.repository.InterestedRegionRepository; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.LocationUpdateRequest; import com.example.solidconnection.siteuser.dto.MyPageResponse; -import com.example.solidconnection.siteuser.dto.NicknameUpdateRequest; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.fixture.SiteUserFixtureBuilder; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.university.domain.LikedUniversity; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import org.junit.jupiter.api.Assertions; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.LikedUnivApplyInfo; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; +import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -22,24 +50,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.crypto.password.PasswordEncoder; -import java.time.LocalDateTime; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; -import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; -import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; -import static com.example.solidconnection.siteuser.service.MyPageService.NICKNAME_LAST_CHANGE_DATE_FORMAT; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.never; -import static org.mockito.BDDMockito.then; - +@TestContainerSpringBootTest @DisplayName("마이페이지 서비스 테스트") -class MyPageServiceTest extends BaseIntegrationTest { +class MyPageServiceTest { @Autowired private MyPageService myPageService; @@ -51,46 +66,129 @@ class MyPageServiceTest extends BaseIntegrationTest { private SiteUserRepository siteUserRepository; @Autowired - private LikedUniversityRepository likedUniversityRepository; + private LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; + + @Autowired + private InterestedCountryRepository interestedCountryRepository; + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private CountryFixture countryFixture; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + @Autowired + private RegionFixture regionFixture; + + @Autowired + private SiteUserFixtureBuilder siteUserFixtureBuilder; + + @Autowired + private PasswordEncoder passwordEncoder; + + private SiteUser user; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + } @Test - void 마이페이지_정보를_조회한다() { + void 멘티의_마이페이지_정보를_조회한다() { // given - SiteUser testUser = createSiteUser(); - int likedUniversityCount = createLikedUniversities(testUser); + int likedUnivApplyInfoCount = createLikedUnivApplyInfos(user); + Country country = countryFixture.미국(); + InterestedCountry interestedCountry = new InterestedCountry(user, country); + interestedCountryRepository.save(interestedCountry); // when - MyPageResponse response = myPageService.getMyPageInfo(testUser); + MyPageResponse response = myPageService.getMyPageInfo(user.getId()); // then - Assertions.assertAll( - () -> assertThat(response.nickname()).isEqualTo(testUser.getNickname()), - () -> assertThat(response.profileImageUrl()).isEqualTo(testUser.getProfileImageUrl()), - () -> assertThat(response.role()).isEqualTo(testUser.getRole()), - () -> assertThat(response.email()).isEqualTo(testUser.getEmail()), - () -> assertThat(response.likedPostCount()).isEqualTo(testUser.getPostLikeList().size()), - () -> assertThat(response.likedUniversityCount()).isEqualTo(likedUniversityCount) + assertAll( + () -> assertThat(response.nickname()).isEqualTo(user.getNickname()), + () -> assertThat(response.profileImageUrl()).isEqualTo(user.getProfileImageUrl()), + () -> assertThat(response.role()).isEqualTo(user.getRole()), + () -> assertThat(response.email()).isEqualTo(user.getEmail()), + // () -> assertThat(response.likedPostCount()).isEqualTo(user.getLikedPostList().size()), + // todo : 좋아요한 게시물 수 반환 기능 추가와 함께 수정요망 + () -> assertThat(response.likedUnivApplyInfoCount()).isEqualTo(likedUnivApplyInfoCount), + () -> assertThat(response.interestedCountries().get(0)).isEqualTo(country.getKoreanName()), + () -> assertThat(response.attendedUniversity()).isNull() ); } @Test - void 관심_대학교_목록을_조회한다() { + void 멘토의_마이페이지_정보를_조회한다() { // given - SiteUser testUser = createSiteUser(); - int likedUniversityCount = createLikedUniversities(testUser); + SiteUser mentorUser = siteUserFixture.멘토(1, "mentor"); + University university = univApplyInfoFixture.괌대학_A_지원_정보().getUniversity(); + mentorFixture.멘토(mentorUser.getId(), university.getId()); + int likedUnivApplyInfoCount = createLikedUnivApplyInfos(mentorUser); // when - List response = myPageService.getWishUniversity(testUser); + MyPageResponse response = myPageService.getMyPageInfo(mentorUser.getId()); // then - assertThat(response) - .hasSize(likedUniversityCount) - .usingRecursiveFieldByFieldElementComparatorIgnoringFields("id") - .containsAll(List.of( - UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) - )); + assertAll( + () -> assertThat(response.nickname()).isEqualTo(mentorUser.getNickname()), + () -> assertThat(response.profileImageUrl()).isEqualTo(mentorUser.getProfileImageUrl()), + () -> assertThat(response.role()).isEqualTo(mentorUser.getRole()), + () -> assertThat(response.email()).isEqualTo(mentorUser.getEmail()), + // () -> assertThat(response.likedPostCount()).isEqualTo(user.getLikedPostList().size()), + // todo : 좋아요한 게시물 수 반환 기능 추가와 함께 수정요망 + () -> assertThat(response.likedUnivApplyInfoCount()).isEqualTo(likedUnivApplyInfoCount), + () -> assertThat(response.attendedUniversity()).isEqualTo(university.getKoreanName()), + () -> assertThat(response.interestedCountries()).isNull() + ); + } + + private int createLikedUnivApplyInfos(SiteUser testUser) { + LikedUnivApplyInfo likedUnivApplyInfo1 = new LikedUnivApplyInfo(null, univApplyInfoFixture.괌대학_A_지원_정보().getId(), testUser.getId()); + LikedUnivApplyInfo likedUnivApplyInfo2 = new LikedUnivApplyInfo(null, univApplyInfoFixture.메이지대학_지원_정보().getId(), testUser.getId()); + LikedUnivApplyInfo likedUnivApplyInfo3 = new LikedUnivApplyInfo(null, univApplyInfoFixture.코펜하겐IT대학_지원_정보().getId(), testUser.getId()); + + likedUnivApplyInfoRepository.save(likedUnivApplyInfo1); + likedUnivApplyInfoRepository.save(likedUnivApplyInfo2); + likedUnivApplyInfoRepository.save(likedUnivApplyInfo3); + return likedUnivApplyInfoRepository.countBySiteUserId(testUser.getId()); + } + + private MockMultipartFile createValidImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + private String createExpectedErrorMessage(LocalDateTime modifiedAt) { + String formatLastModifiedAt = String.format( + "(마지막 수정 시간 : %s)", + NICKNAME_LAST_CHANGE_DATE_FORMAT.format(modifiedAt) + ); + return CAN_NOT_CHANGE_NICKNAME_YET.getMessage() + " : " + formatLastModifiedAt; + } + + private SiteUser createSiteUserWithCustomProfile() { + return siteUserFixtureBuilder.siteUser() + .email("customProfile@example.com") + .authType(AuthType.EMAIL) + .nickname("커스텀프로필") + .profileImageUrl("profile/profileImageUrl") + .role(Role.MENTEE) + .password("customPassword123") + .create(); } @Nested @@ -99,47 +197,46 @@ class 프로필_이미지_수정_테스트 { @Test void 새로운_이미지로_성공적으로_업데이트한다() { // given - SiteUser testUser = createSiteUser(); String expectedUrl = "newProfileImageUrl"; MockMultipartFile imageFile = createValidImageFile(); given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) .willReturn(new UploadedFileUrlResponse(expectedUrl)); // when - myPageService.updateMyPageInfo(testUser, imageFile, "newNickname"); + myPageService.updateMyPageInfo(user.getId(), imageFile, "newNickname"); // then - assertThat(testUser.getProfileImageUrl()).isEqualTo(expectedUrl); + SiteUser updatedUser = siteUserRepository.findById(user.getId()).get(); + assertThat(updatedUser.getProfileImageUrl()).isEqualTo(expectedUrl); } @Test void 프로필을_처음_수정하는_것이면_이전_이미지를_삭제하지_않는다() { // given - SiteUser testUser = createSiteUser(); MockMultipartFile imageFile = createValidImageFile(); given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); // when - myPageService.updateMyPageInfo(testUser, imageFile, "newNickname"); + myPageService.updateMyPageInfo(user.getId(), imageFile, "newNickname"); // then - then(s3Service).should(never()).deleteExProfile(any()); + then(s3Service).should(never()).deleteExProfile(user.getId()); } @Test void 프로필을_처음_수정하는_것이_아니라면_이전_이미지를_삭제한다() { // given - SiteUser testUser = createSiteUserWithCustomProfile(); + SiteUser 커스텀_프로필_사용자 = createSiteUserWithCustomProfile(); MockMultipartFile imageFile = createValidImageFile(); given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); // when - myPageService.updateMyPageInfo(testUser, imageFile, "newNickname"); + myPageService.updateMyPageInfo(커스텀_프로필_사용자.getId(), imageFile, "newNickname"); // then - then(s3Service).should().deleteExProfile(testUser); + then(s3Service).should().deleteExProfile(커스텀_프로필_사용자.getId()); } } @@ -155,108 +252,165 @@ void setUp() { @Test void 닉네임을_성공적으로_수정한다() { // given - SiteUser testUser = createSiteUser(); MockMultipartFile imageFile = createValidImageFile(); String newNickname = "newNickname"; // when - myPageService.updateMyPageInfo(testUser, imageFile, newNickname); + myPageService.updateMyPageInfo(user.getId(), imageFile, newNickname); // then - SiteUser updatedUser = siteUserRepository.findById(testUser.getId()).get(); + SiteUser updatedUser = siteUserRepository.findById(user.getId()).get(); assertThat(updatedUser.getNicknameModifiedAt()).isNotNull(); assertThat(updatedUser.getNickname()).isEqualTo(newNickname); } @Test - void 중복된_닉네임으로_변경하면_예외_응답을_반환한다() { + void 최소_대기기간이_지나지_않은_상태에서_변경하면_예외가_발생한다() { // given - createDuplicatedSiteUser(); - SiteUser testUser = createSiteUser(); MockMultipartFile imageFile = createValidImageFile(); + LocalDateTime modifiedAt = LocalDateTime.now().minusDays(MIN_DAYS_BETWEEN_NICKNAME_CHANGES - 1); + user.setNicknameModifiedAt(modifiedAt); + siteUserRepository.save(user); // when & then - assertThatCode(() -> myPageService.updateMyPageInfo(testUser, imageFile, "duplicatedNickname")) + assertThatCode(() -> myPageService.updateMyPageInfo(user.getId(), imageFile, "nickname12")) .isInstanceOf(CustomException.class) - .hasMessage(NICKNAME_ALREADY_EXISTED.getMessage()); + .hasMessage(createExpectedErrorMessage(modifiedAt)); + } + } + + @Nested + class 비밀번호_변경_테스트 { + + private String currentPassword; + private String newPassword; + + @BeforeEach + void setUp() { + currentPassword = "currentPassword123"; + newPassword = "newPassword123"; + + user.updatePassword(passwordEncoder.encode(currentPassword)); + siteUserRepository.save(user); } @Test - void 최소_대기기간이_지나지_않은_상태에서_변경하면_예외_응답을_반환한다() { + void 비밀번호를_성공적으로_변경한다() { // given - SiteUser testUser = createSiteUser(); - MockMultipartFile imageFile = createValidImageFile(); - LocalDateTime modifiedAt = LocalDateTime.now().minusDays(MIN_DAYS_BETWEEN_NICKNAME_CHANGES - 1); - testUser.setNicknameModifiedAt(modifiedAt); - siteUserRepository.save(testUser); + PasswordUpdateRequest request = new PasswordUpdateRequest(currentPassword, newPassword, newPassword); - NicknameUpdateRequest request = new NicknameUpdateRequest("newNickname"); + // when + myPageService.updatePassword(user.getId(), request); + + // then + SiteUser updatedUser = siteUserRepository.findById(user.getId()).get(); + assertAll( + () -> assertThat(passwordEncoder.matches(newPassword, updatedUser.getPassword())).isTrue(), + () -> assertThat(passwordEncoder.matches(currentPassword, updatedUser.getPassword())).isFalse() + ); + } + + @Test + void 현재_비밀번호가_일치하지_않으면_예외가_발생한다() { + // given + String wrongPassword = "wrongPassword"; + PasswordUpdateRequest request = new PasswordUpdateRequest(wrongPassword, newPassword, newPassword); // when & then - assertThatCode(() -> myPageService.updateMyPageInfo(testUser, imageFile, "nickname12")) + assertThatThrownBy(() -> myPageService.updatePassword(user.getId(), request)) .isInstanceOf(CustomException.class) - .hasMessage(createExpectedErrorMessage(modifiedAt)); + .hasMessage(PASSWORD_MISMATCH.getMessage()); } } - private SiteUser createSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } + @Nested + class 관심_지역_및_국가_변경_테스트 { - private SiteUser createSiteUserWithCustomProfile() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profile/profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } + private Country 미국; + private Country 캐나다; + private Country 일본; + private Region 영미권; + private Region 아시아; - private void createDuplicatedSiteUser() { - SiteUser siteUser = new SiteUser( - "duplicated@example.com", - "duplicatedNickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - siteUserRepository.save(siteUser); - } + @BeforeEach + void setUp() { + 미국 = countryFixture.미국(); + 캐나다 = countryFixture.캐나다(); + 일본 = countryFixture.일본(); + 영미권 = regionFixture.영미권(); + 아시아 = regionFixture.아시아(); + } - private int createLikedUniversities(SiteUser testUser) { - LikedUniversity likedUniversity1 = new LikedUniversity(null, 괌대학_A_지원_정보, testUser); - LikedUniversity likedUniversity2 = new LikedUniversity(null, 메이지대학_지원_정보, testUser); - LikedUniversity likedUniversity3 = new LikedUniversity(null, 코펜하겐IT대학_지원_정보, testUser); + @Test + void 관심_지역과_국가를_성공적으로_수정한다() { + // given + interestedCountryRepository.save(new InterestedCountry(user, 미국)); + interestedRegionRepository.save(new InterestedRegion(user, 영미권)); - likedUniversityRepository.save(likedUniversity1); - likedUniversityRepository.save(likedUniversity2); - likedUniversityRepository.save(likedUniversity3); - return likedUniversityRepository.countBySiteUser_Id(testUser.getId()); - } + List newCountries = List.of(캐나다.getKoreanName(), 일본.getKoreanName()); + List newRegions = List.of(아시아.getKoreanName()); + LocationUpdateRequest request = new LocationUpdateRequest(newRegions, newCountries); - private MockMultipartFile createValidImageFile() { - return new MockMultipartFile( - "image", - "test.jpg", - "image/jpeg", - "test image content".getBytes() - ); - } + // when + myPageService.updateLocation(user.getId(), request); - private String createExpectedErrorMessage(LocalDateTime modifiedAt) { - String formatLastModifiedAt = String.format( - "(마지막 수정 시간 : %s)", - NICKNAME_LAST_CHANGE_DATE_FORMAT.format(modifiedAt) - ); - return CAN_NOT_CHANGE_NICKNAME_YET.getMessage() + " : " + formatLastModifiedAt; + // then + List updatedCountries = interestedCountryRepository.findAllBySiteUserId(user.getId()); + List updatedRegions = interestedRegionRepository.findAllBySiteUserId(user.getId()); + + assertAll( + () -> assertThat(updatedCountries) + .extracting(InterestedCountry::getCountryCode) + .containsExactlyInAnyOrder(캐나다.getCode(), 일본.getCode()), + () -> assertThat(updatedRegions) + .extracting(InterestedRegion::getRegionCode) + .containsExactly(아시아.getCode()) + ); + } + + @Test + void 기존에_관심_지역과_국가가_없어도_성공적으로_추가된다() { + // given + List newCountries = List.of(미국.getKoreanName()); + List newRegions = List.of(영미권.getKoreanName()); + LocationUpdateRequest request = new LocationUpdateRequest(newRegions, newCountries); + + // when + myPageService.updateLocation(user.getId(), request); + + // then + List updatedCountries = interestedCountryRepository.findAllBySiteUserId(user.getId()); + List updatedRegions = interestedRegionRepository.findAllBySiteUserId(user.getId()); + + assertAll( + () -> assertThat(updatedCountries) + .extracting(InterestedCountry::getCountryCode) + .containsExactly(미국.getCode()), + () -> assertThat(updatedRegions) + .extracting(InterestedRegion::getRegionCode) + .containsExactly(영미권.getCode()) + ); + } + + @Test + void 빈_리스트를_전달하면_모든_관심_지역과_국가가_삭제된다() { + // given + interestedCountryRepository.save(new InterestedCountry(user, 미국)); + interestedRegionRepository.save(new InterestedRegion(user, 영미권)); + + LocationUpdateRequest request = new LocationUpdateRequest(List.of(), List.of()); + + // when + myPageService.updateLocation(user.getId(), request); + + // then + List updatedCountries = interestedCountryRepository.findAllBySiteUserId(user.getId()); + List updatedRegions = interestedRegionRepository.findAllBySiteUserId(user.getId()); + + assertAll( + () -> assertThat(updatedCountries).isEmpty(), + () -> assertThat(updatedRegions).isEmpty() + ); + } } } diff --git a/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java index 6c9736198..3a81d40e2 100644 --- a/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java @@ -1,33 +1,32 @@ package com.example.solidconnection.siteuser.service; +import static org.assertj.core.api.Assertions.assertThat; + import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.NicknameExistsResponse; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import static org.assertj.core.api.Assertions.assertThat; - +@TestContainerSpringBootTest @DisplayName("유저 서비스 테스트") -class SiteUserServiceTest extends BaseIntegrationTest { +class SiteUserServiceTest { @Autowired private SiteUserService siteUserService; @Autowired - private SiteUserRepository siteUserRepository; + private SiteUserFixture siteUserFixture; - private SiteUser siteUser; + private SiteUser user; @BeforeEach void setUp() { - siteUser = createSiteUser(); + user = siteUserFixture.사용자(); } @Nested @@ -36,7 +35,7 @@ class 닉네임_중복_검사 { @Test void 존재하는_닉네임이면_true를_반환한다() { // when - NicknameExistsResponse response = siteUserService.checkNicknameExists(siteUser.getNickname()); + NicknameExistsResponse response = siteUserService.checkNicknameExists(user.getNickname()); // then assertThat(response.exists()).isTrue(); @@ -51,15 +50,4 @@ class 닉네임_중복_검사 { assertThat(response.exists()).isFalse(); } } - - private SiteUser createSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } } diff --git a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java index aee6a2bc6..b8dd72670 100644 --- a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java +++ b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java @@ -2,14 +2,13 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import java.util.List; +import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Objects; - @Component public class DatabaseCleaner { @@ -38,11 +37,11 @@ private void truncate() { @SuppressWarnings("unchecked") private List getTruncateQueries() { String sql = """ - SELECT CONCAT('TRUNCATE TABLE ', TABLE_NAME, ';') AS q - FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = (SELECT DATABASE()) - AND TABLE_TYPE = 'BASE TABLE' - """; + SELECT CONCAT('TRUNCATE TABLE ', TABLE_NAME, ';') AS q + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_TYPE = 'BASE TABLE' + """; return em.createNativeQuery(sql).getResultList(); } diff --git a/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java index 415b21e78..31f5f6d2a 100644 --- a/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java +++ b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java @@ -1,14 +1,13 @@ package com.example.solidconnection.support; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.context.ContextConfiguration; -import org.testcontainers.junit.jupiter.Testcontainers; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.testcontainers.junit.jupiter.Testcontainers; @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -17,4 +16,5 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface TestContainerDataJpaTest { + } diff --git a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java index 5c5c93742..c99044a43 100644 --- a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java +++ b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java @@ -1,16 +1,17 @@ package com.example.solidconnection.support; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; import org.springframework.test.context.ContextConfiguration; import org.testcontainers.junit.jupiter.Testcontainers; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - +@ComponentScan(basePackages = "com.example.solidconnection") @ExtendWith({DatabaseClearExtension.class}) @ContextConfiguration(initializers = {RedisTestContainer.class, MySQLTestContainer.class}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -19,4 +20,5 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface TestContainerSpringBootTest { + } diff --git a/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java b/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java deleted file mode 100644 index 05b101985..000000000 --- a/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java +++ /dev/null @@ -1,527 +0,0 @@ -package com.example.solidconnection.support.integration; - -import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.application.repository.ApplicationRepository; -import com.example.solidconnection.community.board.domain.Board; -import com.example.solidconnection.community.board.repository.BoardRepository; -import com.example.solidconnection.entity.Country; -import com.example.solidconnection.community.post.domain.PostImage; -import com.example.solidconnection.entity.Region; -import com.example.solidconnection.community.post.domain.Post; -import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.repositories.CountryRepository; -import com.example.solidconnection.community.post.repository.PostImageRepository; -import com.example.solidconnection.repositories.RegionRepository; -import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.DatabaseClearExtension; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.type.VerifyStatus; -import com.example.solidconnection.university.domain.LanguageRequirement; -import com.example.solidconnection.university.domain.University; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.repository.LanguageRequirementRepository; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import com.example.solidconnection.university.repository.UniversityRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; - -import java.util.HashSet; -import java.util.List; - -import static com.example.solidconnection.type.BoardCode.AMERICAS; -import static com.example.solidconnection.type.BoardCode.ASIA; -import static com.example.solidconnection.type.BoardCode.EUROPE; -import static com.example.solidconnection.type.BoardCode.FREE; -import static com.example.solidconnection.type.SemesterAvailableForDispatch.ONE_SEMESTER; -import static com.example.solidconnection.type.TuitionFeeType.HOME_UNIVERSITY_PAYMENT; - -@TestContainerSpringBootTest -@ExtendWith(DatabaseClearExtension.class) -public abstract class BaseIntegrationTest { - - public static SiteUser 테스트유저_1; - public static SiteUser 테스트유저_2; - public static SiteUser 테스트유저_3; - public static SiteUser 테스트유저_4; - public static SiteUser 테스트유저_5; - public static SiteUser 테스트유저_6; - public static SiteUser 테스트유저_7; - public static SiteUser 이전학기_지원자; - - public static Region 영미권; - public static Region 유럽; - public static Region 아시아; - public static Country 미국; - public static Country 캐나다; - public static Country 덴마크; - public static Country 오스트리아; - public static Country 일본; - - public static University 영미권_미국_괌대학; - public static University 영미권_미국_네바다주립대학_라스베이거스; - public static University 영미권_캐나다_메모리얼대학_세인트존스; - public static University 유럽_덴마크_서던덴마크대학교; - public static University 유럽_덴마크_코펜하겐IT대학; - public static University 유럽_오스트리아_그라츠대학; - public static University 유럽_오스트리아_그라츠공과대학; - public static University 유럽_오스트리아_린츠_카톨릭대학; - public static University 아시아_일본_메이지대학; - - public static UniversityInfoForApply 괌대학_A_지원_정보; - public static UniversityInfoForApply 괌대학_B_지원_정보; - public static UniversityInfoForApply 네바다주립대학_라스베이거스_지원_정보; - public static UniversityInfoForApply 메모리얼대학_세인트존스_A_지원_정보; - public static UniversityInfoForApply 서던덴마크대학교_지원_정보; - public static UniversityInfoForApply 코펜하겐IT대학_지원_정보; - public static UniversityInfoForApply 그라츠대학_지원_정보; - public static UniversityInfoForApply 그라츠공과대학_지원_정보; - public static UniversityInfoForApply 린츠_카톨릭대학_지원_정보; - public static UniversityInfoForApply 메이지대학_지원_정보; - - public static Application 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서; - public static Application 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서; - public static Application 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서; - public static Application 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서; - public static Application 테스트유저_6_X_X_X_지원서; - public static Application 테스트유저_7_코펜하겐IT대학_X_X_지원서; - public static Application 이전학기_지원서; - - public static Board 미주권; - public static Board 아시아권; - public static Board 유럽권; - public static Board 자유게시판; - - public static Post 미주권_자유게시글; - public static Post 아시아권_자유게시글; - public static Post 유럽권_자유게시글; - public static Post 자유게시판_자유게시글; - public static Post 미주권_질문게시글; - public static Post 아시아권_질문게시글; - public static Post 유럽권_질문게시글; - public static Post 자유게시판_질문게시글; - - @Autowired - private SiteUserRepository siteUserRepository; - - @Autowired - private RegionRepository regionRepository; - - @Autowired - private CountryRepository countryRepository; - - @Autowired - private UniversityRepository universityRepository; - - @Autowired - private UniversityInfoForApplyRepository universityInfoForApplyRepository; - - @Autowired - private LanguageRequirementRepository languageRequirementRepository; - - @Autowired - private ApplicationRepository applicationRepository; - - @Autowired - private GpaScoreRepository gpaScoreRepository; - - @Autowired - private LanguageTestScoreRepository languageTestScoreRepository; - - @Autowired - private BoardRepository boardRepository; - - @Autowired - private PostRepository postRepository; - - @Autowired - private PostImageRepository postImageRepository; - - @Value("${university.term}") - public String term; - - @BeforeEach - public void setUpBaseData() { - setUpSiteUsers(); - setUpRegions(); - setUpCountries(); - setUpUniversities(); - setUpUniversityInfos(); - setUpLanguageRequirements(); - setUpApplications(); - setUpBoards(); - setUpPosts(); - } - - private void setUpSiteUsers() { - 테스트유저_1 = siteUserRepository.save(new SiteUser( - "test1@example.com", - "nickname1", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 테스트유저_2 = siteUserRepository.save(new SiteUser( - "test2@example.com", - "nickname2", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 테스트유저_3 = siteUserRepository.save(new SiteUser( - "test3@example.com", - "nickname3", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 테스트유저_4 = siteUserRepository.save(new SiteUser( - "test4@example.com", - "nickname4", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 테스트유저_5 = siteUserRepository.save(new SiteUser( - "test5@example.com", - "nickname5", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 테스트유저_6 = siteUserRepository.save(new SiteUser( - "test6@example.com", - "nickname6", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 테스트유저_7 = siteUserRepository.save(new SiteUser( - "test7@example.com", - "nickname7", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - - 이전학기_지원자 = siteUserRepository.save(new SiteUser( - "old@example.com", - "oldNickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE)); - } - - private void setUpRegions() { - 영미권 = regionRepository.save(new Region("AMERICAS", "영미권")); - 유럽 = regionRepository.save(new Region("EUROPE", "유럽")); - 아시아 = regionRepository.save(new Region("ASIA", "아시아")); - } - - private void setUpCountries() { - 미국 = countryRepository.save(new Country("US", "미국", 영미권)); - 캐나다 = countryRepository.save(new Country("CA", "캐나다", 영미권)); - 덴마크 = countryRepository.save(new Country("DK", "덴마크", 유럽)); - 오스트리아 = countryRepository.save(new Country("AT", "오스트리아", 유럽)); - 일본 = countryRepository.save(new Country("JP", "일본", 아시아)); - } - - private void setUpUniversities() { - 영미권_미국_괌대학 = universityRepository.save(new University( - null, "괌대학", "University of Guam", "university_of_guam", - "https://www.uog.edu/admissions/international-students", - "https://www.uog.edu/admissions/course-schedule", - "https://www.uog.edu/life-at-uog/residence-halls/", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/1.png", - null, 미국, 영미권 - )); - - 영미권_미국_네바다주립대학_라스베이거스 = universityRepository.save(new University( - null, "네바다주립대학 라스베이거스", "University of Nevada, Las Vegas", "university_of_nevada_las_vegas", - "https://www.unlv.edu/engineering/eip", - "https://www.unlv.edu/engineering/academic-programs", - "https://www.unlv.edu/housing", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/1.png", - null, 미국, 영미권 - )); - - 영미권_캐나다_메모리얼대학_세인트존스 = universityRepository.save(new University( - null, "메모리얼 대학 세인트존스", "Memorial University of Newfoundland St. John's", "memorial_university_of_newfoundland_st_johns", - "https://mun.ca/goabroad/visiting-students-inbound/", - "https://www.unlv.edu/engineering/academic-programs", - "https://www.mun.ca/residences/", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/1.png", - null, 캐나다, 영미권 - )); - - 유럽_덴마크_서던덴마크대학교 = universityRepository.save(new University( - null, "서던덴마크대학교", "University of Southern Denmark", "university_of_southern_denmark", - "https://www.sdu.dk/en", - "https://www.sdu.dk/en", - "https://www.sdu.dk/en/uddannelse/information_for_international_students/studenthousing", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/1.png", - null, 덴마크, 유럽 - )); - - 유럽_덴마크_코펜하겐IT대학 = universityRepository.save(new University( - null, "코펜하겐 IT대학", "IT University of Copenhagen", "it_university_of_copenhagen", - "https://en.itu.dk/", null, - "https://en.itu.dk/Programmes/Student-Life/Practical-information-for-international-students", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/1.png", - null, 덴마크, 유럽 - )); - - 유럽_오스트리아_그라츠대학 = universityRepository.save(new University( - null, "그라츠 대학", "University of Graz", "university_of_graz", - "https://www.uni-graz.at/en/", - "https://static.uni-graz.at/fileadmin/veranstaltungen/orientation/documents/incstud_application-courses.pdf", - "https://orientation.uni-graz.at/de/planning-the-arrival/accommodation/", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/1.png", - null, 오스트리아, 유럽 - )); - - 유럽_오스트리아_그라츠공과대학 = universityRepository.save(new University( - null, "그라츠공과대학", "Graz University of Technology", "graz_university_of_technology", - "https://www.tugraz.at/en/home", null, - "https://www.tugraz.at/en/studying-and-teaching/studying-internationally/incoming-students-exchange-at-tu-graz/your-stay-at-tu-graz/preparation#c75033", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/1.png", - null, 오스트리아, 유럽 - )); - - 유럽_오스트리아_린츠_카톨릭대학 = universityRepository.save(new University( - null, "린츠 카톨릭 대학교", "Catholic Private University Linz", "catholic_private_university_linz", - "https://ku-linz.at/en", null, - "https://ku-linz.at/en/ku_international/incomings/kulis", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/1.png", - null, 오스트리아, 유럽 - )); - - 아시아_일본_메이지대학 = universityRepository.save(new University( - null, "메이지대학", "Meiji University", "meiji_university", - "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", null, - "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/logo.png", - "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/1.png", - null, 일본, 아시아 - )); - } - - private void setUpUniversityInfos() { - 괌대학_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "괌대학(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 영미권_미국_괌대학 - )); - - 괌대학_B_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "괌대학(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 영미권_미국_괌대학 - )); - - 네바다주립대학_라스베이거스_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "네바다주립대학 라스베이거스(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 영미권_미국_네바다주립대학_라스베이거스 - )); - - 메모리얼대학_세인트존스_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "메모리얼 대학 세인트존스(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 영미권_캐나다_메모리얼대학_세인트존스 - )); - - 서던덴마크대학교_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "서던덴마크대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 유럽_덴마크_서던덴마크대학교 - )); - - 코펜하겐IT대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "코펜하겐 IT대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 유럽_덴마크_코펜하겐IT대학 - )); - - 그라츠대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "그라츠 대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 유럽_오스트리아_그라츠대학 - )); - - 그라츠공과대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "그라츠공과대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 유럽_오스트리아_그라츠공과대학 - )); - - 린츠_카톨릭대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "린츠 카톨릭 대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 유럽_오스트리아_린츠_카톨릭대학 - )); - - 메이지대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( - null, term, "메이지대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, - "1", "detailsForLanguage", "gpaRequirement", - "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", - "detailsForAccommodation", "detailsForEnglishCourse", "details", - new HashSet<>(), 아시아_일본_메이지대학 - )); - } - - private void setUpLanguageRequirements() { - saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEFL_IBT, "80"); - saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEIC, "800"); - saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEFL_IBT, "70"); - saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEIC, "900"); - saveLanguageTestRequirement(네바다주립대학_라스베이거스_지원_정보, LanguageTestType.TOEIC, "800"); - saveLanguageTestRequirement(메모리얼대학_세인트존스_A_지원_정보, LanguageTestType.TOEIC, "800"); - saveLanguageTestRequirement(서던덴마크대학교_지원_정보, LanguageTestType.TOEFL_IBT, "70"); - saveLanguageTestRequirement(코펜하겐IT대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); - saveLanguageTestRequirement(그라츠대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); - saveLanguageTestRequirement(그라츠공과대학_지원_정보, LanguageTestType.TOEIC, "800"); - saveLanguageTestRequirement(린츠_카톨릭대학_지원_정보, LanguageTestType.TOEIC, "800"); - saveLanguageTestRequirement(메이지대학_지원_정보, LanguageTestType.JLPT, "N2"); - } - - private void setUpApplications() { - 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서 = new Application(테스트유저_2, createApprovedGpaScore(테스트유저_2).getGpa(), createApprovedLanguageTestScore(테스트유저_2).getLanguageTest(), - term, 괌대학_B_지원_정보, 괌대학_A_지원_정보, 린츠_카톨릭대학_지원_정보, "user2_nickname"); - - 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서 = new Application(테스트유저_3, createApprovedGpaScore(테스트유저_3).getGpa(), createApprovedLanguageTestScore(테스트유저_3).getLanguageTest(), - term, 괌대학_A_지원_정보, 괌대학_B_지원_정보, 그라츠공과대학_지원_정보, "user3_nickname"); - - 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서 = new Application(테스트유저_4, createApprovedGpaScore(테스트유저_4).getGpa(), createApprovedLanguageTestScore(테스트유저_4).getLanguageTest(), - term, 메이지대학_지원_정보, 그라츠대학_지원_정보, 서던덴마크대학교_지원_정보, "user4_nickname"); - - 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서 = new Application(테스트유저_5, createApprovedGpaScore(테스트유저_5).getGpa(), createApprovedLanguageTestScore(테스트유저_5).getLanguageTest(), - term, 네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "user5_nickname"); - - 테스트유저_6_X_X_X_지원서 = new Application(테스트유저_6, createApprovedGpaScore(테스트유저_6).getGpa(), createApprovedLanguageTestScore(테스트유저_6).getLanguageTest(), - term, null, null, null, "user6_nickname"); - - 테스트유저_7_코펜하겐IT대학_X_X_지원서 = new Application(테스트유저_7, createApprovedGpaScore(테스트유저_7).getGpa(), createApprovedLanguageTestScore(테스트유저_7).getLanguageTest(), - term, 코펜하겐IT대학_지원_정보, null, null, "user7_nickname"); - - 이전학기_지원서 = new Application(이전학기_지원자, createApprovedGpaScore(이전학기_지원자).getGpa(), createApprovedLanguageTestScore(이전학기_지원자).getLanguageTest(), - "1988-1", 네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "old_nickname"); - - 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); - 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); - 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); - 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); - 테스트유저_6_X_X_X_지원서.setVerifyStatus(VerifyStatus.APPROVED); - 테스트유저_7_코펜하겐IT대학_X_X_지원서.setVerifyStatus(VerifyStatus.APPROVED); - 이전학기_지원서.setVerifyStatus(VerifyStatus.APPROVED); - - applicationRepository.saveAll(List.of( - 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, - 테스트유저_6_X_X_X_지원서, 테스트유저_7_코펜하겐IT대학_X_X_지원서, 이전학기_지원서)); - } - - private void setUpBoards() { - 미주권 = boardRepository.save(new Board(AMERICAS.name(), "미주권")); - 아시아권 = boardRepository.save(new Board(ASIA.name(), "아시아권")); - 유럽권 = boardRepository.save(new Board(EUROPE.name(), "유럽권")); - 자유게시판 = boardRepository.save(new Board(FREE.name(), "자유게시판")); - } - - private void setUpPosts() { - 미주권_자유게시글 = createPost(미주권, 테스트유저_1, "미주권 자유게시글", "미주권 자유게시글 내용", PostCategory.자유); - 아시아권_자유게시글 = createPost(아시아권, 테스트유저_2, "아시아권 자유게시글", "아시아권 자유게시글 내용", PostCategory.자유); - 유럽권_자유게시글 = createPost(유럽권, 테스트유저_1, "유럽권 자유게시글", "유럽권 자유게시글 내용", PostCategory.자유); - 자유게시판_자유게시글 = createPost(자유게시판, 테스트유저_2, "자유게시판 자유게시글", "자유게시판 자유게시글 내용", PostCategory.자유); - 미주권_질문게시글 = createPost(미주권, 테스트유저_1, "미주권 질문게시글", "미주권 질문게시글 내용", PostCategory.질문); - 아시아권_질문게시글 = createPost(아시아권, 테스트유저_2, "아시아권 질문게시글", "아시아권 질문게시글 내용", PostCategory.질문); - 유럽권_질문게시글 = createPost(유럽권, 테스트유저_1, "유럽권 질문게시글", "유럽권 질문게시글 내용", PostCategory.질문); - 자유게시판_질문게시글 = createPost(자유게시판, 테스트유저_2, "자유게시판 질문게시글", "자유게시판 질문게시글 내용", PostCategory.질문); - } - - private void saveLanguageTestRequirement( - UniversityInfoForApply universityInfoForApply, - LanguageTestType testType, - String minScore - ) { - LanguageRequirement languageRequirement = new LanguageRequirement( - null, - testType, - minScore, - universityInfoForApply); - universityInfoForApply.addLanguageRequirements(languageRequirement); - universityInfoForApplyRepository.save(universityInfoForApply); - languageRequirementRepository.save(languageRequirement); - } - - private GpaScore createApprovedGpaScore(SiteUser siteUser) { - GpaScore gpaScore = new GpaScore( - new Gpa(4.0, 4.5, "/gpa-report.pdf"), - siteUser - ); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - return gpaScoreRepository.save(gpaScore); - } - - private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), - siteUser - ); - languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); - return languageTestScoreRepository.save(languageTestScore); - } - - private Post createPost(Board board, SiteUser siteUser, String title, String content, PostCategory category) { - Post post = new Post( - title, - content, - false, - 0L, - 0L, - category - ); - post.setBoardAndSiteUser(board, siteUser); - Post savedPost = postRepository.save(post); - PostImage postImage = new PostImage("imageUrl"); - postImage.setPost(savedPost); - postImageRepository.save(postImage); - return savedPost; - } -} diff --git a/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java b/src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java similarity index 51% rename from src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java rename to src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java index b0267a08b..94b7eb7fc 100644 --- a/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java +++ b/src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java @@ -1,23 +1,22 @@ -package com.example.solidconnection.custom.validation.validator; +package com.example.solidconnection.university.dto.validation; -import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_UNIV_APPLY_INFO_CHOICE; +import static com.example.solidconnection.common.exception.ErrorCode.FIRST_CHOICE_REQUIRED; +import static com.example.solidconnection.common.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.Set; - -import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_UNIVERSITY_CHOICE; -import static com.example.solidconnection.custom.exception.ErrorCode.FIRST_CHOICE_REQUIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; -import static org.assertj.core.api.Assertions.assertThat; - @DisplayName("대학 선택 유효성 검사 테스트") -class ValidUniversityChoiceValidatorTest { +class ValidUnivApplyInfoChoiceValidatorTest { private static final String MESSAGE = "message"; @@ -32,10 +31,10 @@ void setUp() { @Test void 정상적인_지망_선택은_유효하다() { // given - UniversityChoiceRequest request = new UniversityChoiceRequest(1L, 2L, 3L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, 2L, 3L); // when - Set> violations = validator.validate(request); + Set> violations = validator.validate(request); // then assertThat(violations).isEmpty(); @@ -44,22 +43,22 @@ void setUp() { @Test void 첫_번째_지망만_선택하는_것은_유효하다() { // given - UniversityChoiceRequest request = new UniversityChoiceRequest(1L, null, null); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, null, null); // when - Set> violations = validator.validate(request); + Set> violations = validator.validate(request); // then assertThat(violations).isEmpty(); } @Test - void 두_번째_지망_없이_세_번째_지망을_선택하면_예외_응답을_반환한다() { + void 두_번째_지망_없이_세_번째_지망을_선택하면_예외가_발생한다() { // given - UniversityChoiceRequest request = new UniversityChoiceRequest(1L, null, 3L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, null, 3L); // when - Set> violations = validator.validate(request); + Set> violations = validator.validate(request); // then assertThat(violations) @@ -68,12 +67,12 @@ void setUp() { } @Test - void 첫_번째_지망을_선택하지_않으면_예외_응답을_반환한다() { + void 첫_번째_지망을_선택하지_않으면_예외가_발생한다() { // given - UniversityChoiceRequest request = new UniversityChoiceRequest(null, 2L, 3L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(null, 2L, 3L); // when - Set> violations = validator.validate(request); + Set> violations = validator.validate(request); // then assertThat(violations) @@ -83,17 +82,17 @@ void setUp() { } @Test - void 대학을_중복_선택하면_예외_응답을_반환한다() { + void 대학을_중복_선택하면_예외가_발생한다() { // given - UniversityChoiceRequest request = new UniversityChoiceRequest(1L, 1L, 2L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, 1L, 2L); // when - Set> violations = validator.validate(request); + Set> violations = validator.validate(request); // then assertThat(violations) .isNotEmpty() .extracting(MESSAGE) - .contains(DUPLICATE_UNIVERSITY_CHOICE.getMessage()); + .contains(DUPLICATE_UNIV_APPLY_INFO_CHOICE.getMessage()); } } diff --git a/src/test/java/com/example/solidconnection/university/fixture/LanguageRequirementFixture.java b/src/test/java/com/example/solidconnection/university/fixture/LanguageRequirementFixture.java new file mode 100644 index 000000000..b70515b89 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/fixture/LanguageRequirementFixture.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.university.fixture; + +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class LanguageRequirementFixture { + + private final LanguageRequirementFixtureBuilder languageRequirementFixtureBuilder; + + public LanguageRequirement 토플_80(UnivApplyInfo univApplyInfo) { + return languageRequirementFixtureBuilder + .languageTestType(LanguageTestType.TOEFL_IBT) + .minScore("80") + .univApplyInfo(univApplyInfo) + .create(); + } + + public LanguageRequirement 토플_70(UnivApplyInfo univApplyInfo) { + return languageRequirementFixtureBuilder + .languageTestType(LanguageTestType.TOEFL_IBT) + .minScore("70") + .univApplyInfo(univApplyInfo) + .create(); + } + + public LanguageRequirement 토익_800(UnivApplyInfo univApplyInfo) { + return languageRequirementFixtureBuilder + .languageTestType(LanguageTestType.TOEIC) + .minScore("800") + .univApplyInfo(univApplyInfo) + .create(); + } + + public LanguageRequirement 토익_900(UnivApplyInfo univApplyInfo) { + return languageRequirementFixtureBuilder + .languageTestType(LanguageTestType.TOEIC) + .minScore("900") + .univApplyInfo(univApplyInfo) + .create(); + } + + public LanguageRequirement JLPT_N2(UnivApplyInfo univApplyInfo) { + return languageRequirementFixtureBuilder + .languageTestType(LanguageTestType.JLPT) + .minScore("N2") + .univApplyInfo(univApplyInfo) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/university/fixture/LanguageRequirementFixtureBuilder.java b/src/test/java/com/example/solidconnection/university/fixture/LanguageRequirementFixtureBuilder.java new file mode 100644 index 000000000..01f03e716 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/fixture/LanguageRequirementFixtureBuilder.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.university.fixture; + +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.LanguageRequirementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class LanguageRequirementFixtureBuilder { + + private final LanguageRequirementRepository languageRequirementRepository; + + private LanguageTestType languageTestType; + private String minScore; + private UnivApplyInfo univApplyInfo; + + public LanguageRequirementFixtureBuilder languageTestType(LanguageTestType languageTestType) { + this.languageTestType = languageTestType; + return this; + } + + public LanguageRequirementFixtureBuilder minScore(String minScore) { + this.minScore = minScore; + return this; + } + + public LanguageRequirementFixtureBuilder univApplyInfo(UnivApplyInfo univApplyInfo) { + this.univApplyInfo = univApplyInfo; + return this; + } + + public LanguageRequirement create() { + LanguageRequirement languageRequirement = new LanguageRequirement( + null, + languageTestType, + minScore, + univApplyInfo + ); + univApplyInfo.addLanguageRequirements(languageRequirement); + return languageRequirementRepository.save(languageRequirement); + } +} diff --git a/src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixture.java b/src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixture.java new file mode 100644 index 000000000..dfd50450f --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixture.java @@ -0,0 +1,105 @@ +package com.example.solidconnection.university.fixture; + +import com.example.solidconnection.university.domain.UnivApplyInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UnivApplyInfoFixture { + + private final UnivApplyInfoFixtureBuilder univApplyInfoFixtureBuilder; + private final UniversityFixture universityFixture; + + @Value("${university.term}") + public String term; + + public UnivApplyInfo 괌대학_A_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("괌대학(A형)") + .university(universityFixture.괌_대학()) + .create(); + } + + public UnivApplyInfo 괌대학_B_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("괌대학(B형)") + .university(universityFixture.괌_대학()) + .create(); + } + + public UnivApplyInfo 네바다주립대학_라스베이거스_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("네바다주립대학 라스베이거스(B형)") + .university(universityFixture.네바다주립_대학_라스베이거스()) + .create(); + } + + public UnivApplyInfo 아칸소주립대학_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("아칸소 주립 대학") + .university(universityFixture.아칸소_주립_대학()) + .create(); + } + + public UnivApplyInfo 메모리얼대학_세인트존스_A_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("메모리얼 대학 세인트존스(A형)") + .university(universityFixture.메모리얼_대학_세인트존스()) + .create(); + } + + public UnivApplyInfo 서던덴마크대학교_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("서던덴마크대학교") + .university(universityFixture.서던덴마크_대학()) + .create(); + } + + public UnivApplyInfo 코펜하겐IT대학_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("코펜하겐 IT대학") + .university(universityFixture.코펜하겐IT_대학()) + .create(); + } + + public UnivApplyInfo 그라츠대학_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("그라츠 대학") + .university(universityFixture.그라츠_대학()) + .create(); + } + + public UnivApplyInfo 그라츠공과대학_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("그라츠공과대학") + .university(universityFixture.그라츠공과_대학()) + .create(); + } + + public UnivApplyInfo 린츠_카톨릭대학_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("린츠 카톨릭 대학교") + .university(universityFixture.린츠_카톨릭_대학()) + .create(); + } + + public UnivApplyInfo 메이지대학_지원_정보() { + return univApplyInfoFixtureBuilder.univApplyInfo() + .term(term) + .koreanName("메이지대학") + .university(universityFixture.메이지_대학()) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixtureBuilder.java b/src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixtureBuilder.java new file mode 100644 index 000000000..55f81e9eb --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixtureBuilder.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.university.fixture; + +import static com.example.solidconnection.university.domain.SemesterAvailableForDispatch.ONE_SEMESTER; +import static com.example.solidconnection.university.domain.TuitionFeeType.HOME_UNIVERSITY_PAYMENT; + +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.HashSet; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UnivApplyInfoFixtureBuilder { + + private final UnivApplyInfoRepository univApplyInfoRepository; + + private String term; + private String koreanName; + private University university; + + public UnivApplyInfoFixtureBuilder univApplyInfo() { + return new UnivApplyInfoFixtureBuilder(univApplyInfoRepository); + } + + public UnivApplyInfoFixtureBuilder term(String term) { + this.term = term; + return this; + } + + public UnivApplyInfoFixtureBuilder koreanName(String koreanName) { + this.koreanName = koreanName; + return this; + } + + public UnivApplyInfoFixtureBuilder university(University university) { + this.university = university; + return this; + } + + public UnivApplyInfo create() { + UnivApplyInfo univApplyInfo = new UnivApplyInfo( + null, term, koreanName, 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), university + ); + return univApplyInfoRepository.save(univApplyInfo); + } +} diff --git a/src/test/java/com/example/solidconnection/university/fixture/UniversityFixture.java b/src/test/java/com/example/solidconnection/university/fixture/UniversityFixture.java new file mode 100644 index 000000000..bbc3fc3b4 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/fixture/UniversityFixture.java @@ -0,0 +1,106 @@ +package com.example.solidconnection.university.fixture; + +import com.example.solidconnection.location.country.fixture.CountryFixture; +import com.example.solidconnection.location.region.fixture.RegionFixture; +import com.example.solidconnection.university.domain.University; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public final class UniversityFixture { + + private final RegionFixture regionFixture; + private final CountryFixture countryFixture; + private final UniversityFixtureBuilder universityFixtureBuilder; + + public University 괌_대학() { + return universityFixtureBuilder.university() + .koreanName("괌 대학") + .englishName("University of Guam") + .country(countryFixture.미국()) + .region(regionFixture.영미권()) + .create(); + } + + public University 네바다주립_대학_라스베이거스() { + return universityFixtureBuilder.university() + .koreanName("네바다주립 대학 라스베이거스") + .englishName("University of Nevada, Las Vegas") + .country(countryFixture.미국()) + .region(regionFixture.영미권()) + .create(); + } + + public University 아칸소_주립_대학() { + return universityFixtureBuilder.university() + .koreanName("아칸소 주립 대학") + .englishName("Arkansas State University") + .country(countryFixture.미국()) + .region(regionFixture.영미권()) + .create(); + } + + public University 메모리얼_대학_세인트존스() { + return universityFixtureBuilder.university() + .koreanName("메모리얼 대학 세인트존스") + .englishName("Memorial University of Newfoundland St. John's") + .country(countryFixture.캐나다()) + .region(regionFixture.영미권()) + .create(); + } + + public University 서던덴마크_대학() { + return universityFixtureBuilder.university() + .koreanName("서던덴마크 대학") + .englishName("University of Southern Denmark") + .country(countryFixture.덴마크()) + .region(regionFixture.유럽()) + .create(); + } + + public University 코펜하겐IT_대학() { + return universityFixtureBuilder.university() + .koreanName("코펜하겐IT 대학") + .englishName("IT University of Copenhagen") + .country(countryFixture.덴마크()) + .region(regionFixture.유럽()) + .create(); + } + + public University 그라츠_대학() { + return universityFixtureBuilder.university() + .koreanName("그라츠 대학") + .englishName("University of Graz") + .country(countryFixture.오스트리아()) + .region(regionFixture.유럽()) + .create(); + } + + public University 그라츠공과_대학() { + return universityFixtureBuilder.university() + .koreanName("그라츠공과 대학") + .englishName("Graz University of Technology") + .country(countryFixture.오스트리아()) + .region(regionFixture.유럽()) + .create(); + } + + public University 린츠_카톨릭_대학() { + return universityFixtureBuilder.university() + .koreanName("린츠 카톨릭 대학") + .englishName("Catholic Private University Linz") + .country(countryFixture.오스트리아()) + .region(regionFixture.유럽()) + .create(); + } + + public University 메이지_대학() { + return universityFixtureBuilder.university() + .koreanName("메이지 대학") + .englishName("Meiji University") + .country(countryFixture.일본()) + .region(regionFixture.아시아()) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/university/fixture/UniversityFixtureBuilder.java b/src/test/java/com/example/solidconnection/university/fixture/UniversityFixtureBuilder.java new file mode 100644 index 000000000..4da6cdfd7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/fixture/UniversityFixtureBuilder.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.university.fixture; + +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.UniversityRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UniversityFixtureBuilder { + + private final UniversityRepository universityRepository; + + private String koreanName; + private String englishName; + private Country country; + private Region region; + + public UniversityFixtureBuilder university() { + return new UniversityFixtureBuilder(universityRepository); + } + + public UniversityFixtureBuilder koreanName(String koreanName) { + this.koreanName = koreanName; + return this; + } + + public UniversityFixtureBuilder englishName(String englishName) { + this.englishName = englishName; + return this; + } + + public UniversityFixtureBuilder country(Country country) { + this.country = country; + return this; + } + + public UniversityFixtureBuilder region(Region region) { + this.region = region; + return this; + } + + public University create() { + University university = new University( + null, koreanName, englishName, + "formatName", + "https://homepage-url", + "https://english-course-url", + "https://accommodation-url", + "https://logo-image-url", + "https://background-image-url", + null, country, region + ); + return universityRepository.save(university); + } +} diff --git a/src/test/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepositoryTest.java b/src/test/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepositoryTest.java new file mode 100644 index 000000000..785b501b5 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepositoryTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.university.repository; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.LikedUnivApplyInfo; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +@TestContainerSpringBootTest +@DisplayName("대학교 좋아요 레파지토리 테스트") +public class LikedUnivApplyInfoRepositoryTest { + + @Autowired + private LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + @Nested + class 사용자와_좋아요한_대학은_복합_유니크_제약조건을_갖는다 { + + @Test + void 같은_사용자가_같은_대학에_중복으로_좋아요하면_예외가_발생한다() { + // given + SiteUser user = siteUserFixture.사용자(); + UnivApplyInfo univApplyInfo = univApplyInfoFixture.괌대학_A_지원_정보(); + + LikedUnivApplyInfo firstLike = createLikedUnivApplyInfo(user, univApplyInfo); + likedUnivApplyInfoRepository.save(firstLike); + + LikedUnivApplyInfo secondLike = createLikedUnivApplyInfo(user, univApplyInfo); + + // when & then + assertThatCode(() -> likedUnivApplyInfoRepository.save(secondLike)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 다른_사용자가_같은_대학에_좋아요하면_정상_저장된다() { + // given + SiteUser user1 = siteUserFixture.사용자(1, "user1"); + SiteUser user2 = siteUserFixture.사용자(2, "user2"); + UnivApplyInfo univApplyInfo = univApplyInfoFixture.괌대학_A_지원_정보(); + + LikedUnivApplyInfo firstLike = createLikedUnivApplyInfo(user1, univApplyInfo); + likedUnivApplyInfoRepository.save(firstLike); + + LikedUnivApplyInfo secondLike = createLikedUnivApplyInfo(user2, univApplyInfo); + + // when & then + assertThatCode(() -> likedUnivApplyInfoRepository.save(secondLike)).doesNotThrowAnyException(); + } + + @Test + void 같은_사용자가_다른_대학에_좋아요하면_정상_저장된다() { + // given + SiteUser user = siteUserFixture.사용자(); + UnivApplyInfo univApplyInfo1 = univApplyInfoFixture.괌대학_A_지원_정보(); + UnivApplyInfo univApplyInfo2 = univApplyInfoFixture.메이지대학_지원_정보(); + + LikedUnivApplyInfo firstLike = createLikedUnivApplyInfo(user, univApplyInfo1); + likedUnivApplyInfoRepository.save(firstLike); + + LikedUnivApplyInfo secondLike = createLikedUnivApplyInfo(user, univApplyInfo2); + + // when & then + assertThatCode(() -> likedUnivApplyInfoRepository.save(secondLike)).doesNotThrowAnyException(); + } + } + + private LikedUnivApplyInfo createLikedUnivApplyInfo(SiteUser siteUser, UnivApplyInfo univApplyInfo) { + return LikedUnivApplyInfo.builder() + .siteUserId(siteUser.getId()) + .univApplyInfoId(univApplyInfo.getId()) + .build(); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java new file mode 100644 index 000000000..4e22897ef --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/GeneralUnivApplyInfoRecommendServiceTest.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.university.service; + +import static com.example.solidconnection.university.service.UnivApplyInfoRecommendService.RECOMMEND_UNIV_APPLY_INFO_NUM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +@TestContainerSpringBootTest +@DisplayName("대학 지원 정보 공통 추천 서비스 테스트") +class GeneralUnivApplyInfoRecommendServiceTest { + + @Autowired + private GeneralUnivApplyInfoRecommendService generalUnivApplyInfoRecommendService; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + @Value("${university.term}") + private String term; + + @BeforeEach + void setUp() { + univApplyInfoFixture.괌대학_A_지원_정보(); + univApplyInfoFixture.괌대학_B_지원_정보(); + univApplyInfoFixture.네바다주립대학_라스베이거스_지원_정보(); + univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보(); + univApplyInfoFixture.서던덴마크대학교_지원_정보(); + univApplyInfoFixture.코펜하겐IT대학_지원_정보(); + univApplyInfoFixture.그라츠대학_지원_정보(); + univApplyInfoFixture.그라츠공과대학_지원_정보(); + univApplyInfoFixture.린츠_카톨릭대학_지원_정보(); + univApplyInfoFixture.메이지대학_지원_정보(); + generalUnivApplyInfoRecommendService.init(); + } + + @Test + void 모집_시기의_대학_지원_정보_중에서_랜덤하게_N개를_추천_목록으로_구성한다() { + // given + List universities = generalUnivApplyInfoRecommendService.getGeneralRecommends(); + + // when & then + assertAll( + () -> assertThat(universities) + .extracting("term") + .allMatch(term::equals), + () -> assertThat(universities).hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java deleted file mode 100644 index d93765a44..000000000 --- a/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -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 java.util.List; - -import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@DisplayName("공통 추천 대학 서비스 테스트") -@TestContainerSpringBootTest -class GeneralUniversityRecommendServiceTest extends BaseIntegrationTest { - - @Autowired - private GeneralUniversityRecommendService generalUniversityRecommendService; - - @Value("${university.term}") - private String term; - - @Test - void 모집_시기의_대학들_중에서_랜덤하게_N개를_추천_목록으로_구성한다() { - // given - generalUniversityRecommendService.init(); - List universities = generalUniversityRecommendService.getRecommendUniversities(); - - // when & then - assertAll( - () -> assertThat(universities) - .extracting("term") - .allMatch(term::equals), - () -> assertThat(universities).hasSize(RECOMMEND_UNIVERSITY_NUM) - ); - } -} diff --git a/src/test/java/com/example/solidconnection/university/service/LikedUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/university/service/LikedUnivApplyInfoServiceTest.java new file mode 100644 index 000000000..2693d4501 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/LikedUnivApplyInfoServiceTest.java @@ -0,0 +1,169 @@ +package com.example.solidconnection.university.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_LIKED_UNIV_APPLY_INFO; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_LIKED_UNIV_APPLY_INFO; +import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.LikedUnivApplyInfo; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("대학 지원 정보 좋아요 서비스 테스트") +class LikedUnivApplyInfoServiceTest { + + @Autowired + private LikedUnivApplyInfoService likedUnivApplyInfoService; + + @Autowired + private LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + private SiteUser user; + private UnivApplyInfo 괌대학_A_지원_정보; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + } + + @Test + void 관심_대학_지원_정보_목록을_조회한다() { + // given + UnivApplyInfo 메이지대학_지원_정보 = univApplyInfoFixture.메이지대학_지원_정보(); + UnivApplyInfo 그라츠대학_지원_정보 = univApplyInfoFixture.그라츠대학_지원_정보(); + saveLikedUnivApplyInfo(user, 메이지대학_지원_정보); + saveLikedUnivApplyInfo(user, 그라츠대학_지원_정보); + + // when + List response = likedUnivApplyInfoService.getLikedUnivApplyInfos(user.getId()); + + // then + assertThat(response).extracting(UnivApplyInfoPreviewResponse::id) + .containsExactlyInAnyOrder(메이지대학_지원_정보.getId(), 그라츠대학_지원_정보.getId()); + } + + @Nested + class 대학_지원_정보_좋아요를_등록한다 { + + @Test + void 성공적으로_좋아요를_등록한다() { + // when + likedUnivApplyInfoService.addUnivApplyInfoLike(user.getId(), 괌대학_A_지원_정보.getId()); + + // then + assertThat( + likedUnivApplyInfoRepository.findBySiteUserIdAndUnivApplyInfoId(user.getId(), 괌대학_A_지원_정보.getId()) + ).isPresent(); + } + + @Test + void 이미_좋아요했으면_예외가_발생한다() { + // given + saveLikedUnivApplyInfo(user, 괌대학_A_지원_정보); + + // when & then + assertThatCode(() -> likedUnivApplyInfoService.addUnivApplyInfoLike(user.getId(), 괌대학_A_지원_정보.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_LIKED_UNIV_APPLY_INFO.getMessage()); + } + } + + @Nested + class 대학_지원_정보_좋아요를_취소한다 { + + @Test + void 성공적으로_좋아요를_취소한다() { + // given + saveLikedUnivApplyInfo(user, 괌대학_A_지원_정보); + + // when + likedUnivApplyInfoService.cancelUnivApplyInfoLike(user.getId(), 괌대학_A_지원_정보.getId()); + + // then + assertThat( + likedUnivApplyInfoRepository.findBySiteUserIdAndUnivApplyInfoId(user.getId(), 괌대학_A_지원_정보.getId()) + ).isEmpty(); + } + + @Test + void 좋아요하지_않았으면_예외가_발생한다() { + // when & then + assertThatCode(() -> likedUnivApplyInfoService.cancelUnivApplyInfoLike(user.getId(), 괌대학_A_지원_정보.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_LIKED_UNIV_APPLY_INFO.getMessage()); + } + } + + @Test + void 존재하지_않는_지원_정보에_좋아요_시도하면_예외가_발생한다() { + // given + Long invalidUnivApplyInfoId = 9999L; + + // when & then + assertThatCode(() -> likedUnivApplyInfoService.addUnivApplyInfoLike(user.getId(), invalidUnivApplyInfoId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIV_APPLY_INFO_NOT_FOUND.getMessage()); + } + + @Test + void 좋아요한_대학_지원_정보인지_확인한다() { + // given + saveLikedUnivApplyInfo(user, 괌대학_A_지원_정보); + + // when + IsLikeResponse response = likedUnivApplyInfoService.isUnivApplyInfoLiked(user.getId(), 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.isLike()).isTrue(); + } + + @Test + void 좋아요하지_않은_대학_지원_정보인지_확인한다() { + // when + IsLikeResponse response = likedUnivApplyInfoService.isUnivApplyInfoLiked(user.getId(), 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.isLike()).isFalse(); + } + + @Test + void 존재하지_않는_대학_지원_정보의_좋아요_여부를_조회하면_예외가_발생한다() { + // given + Long invalidUnivApplyInfoId = 9999L; + + // when & then + assertThatCode(() -> likedUnivApplyInfoService.isUnivApplyInfoLiked(user.getId(), invalidUnivApplyInfoId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIV_APPLY_INFO_NOT_FOUND.getMessage()); + } + + private void saveLikedUnivApplyInfo(SiteUser siteUser, UnivApplyInfo univApplyInfo) { + LikedUnivApplyInfo likedUnivApplyInfo = LikedUnivApplyInfo.builder() + .siteUserId(siteUser.getId()) + .univApplyInfoId(univApplyInfo.getId()) + .build(); + likedUnivApplyInfoRepository.save(likedUnivApplyInfo); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoQueryServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoQueryServiceTest.java new file mode 100644 index 000000000..661294363 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoQueryServiceTest.java @@ -0,0 +1,283 @@ +package com.example.solidconnection.university.service; + +import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND; +import static com.example.solidconnection.university.domain.LanguageTestType.TOEIC; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.UnivApplyInfoDetailResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoFilterSearchRequest; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponses; +import com.example.solidconnection.university.fixture.LanguageRequirementFixture; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.mock.mockito.SpyBean; + +@TestContainerSpringBootTest +@DisplayName("대학 지원 정보 조회 서비스 테스트") +class UnivApplyInfoQueryServiceTest { + + @Autowired + private UnivApplyInfoQueryService univApplyInfoQueryService; + + @SpyBean + private UnivApplyInfoRepository univApplyInfoRepository; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + @Autowired + private LanguageRequirementFixture languageRequirementFixture; + + @Value("${university.term}") + public String term; + + @Nested + class 대학_지원_정보_상세_조회 { + + @Test + void 대학_지원_정보를_상세_조회한다() { + // given + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + + // when + UnivApplyInfoDetailResponse response = univApplyInfoQueryService.getUnivApplyInfoDetail(괌대학_A_지원_정보.getId()); + + // then + assertThat(response.id()).isEqualTo(괌대학_A_지원_정보.getId()); + } + + @Test + void 대학_지원_정보_상세_조회시_캐시가_적용된다() { + // given + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + + // when + UnivApplyInfoDetailResponse firstResponse = univApplyInfoQueryService.getUnivApplyInfoDetail(괌대학_A_지원_정보.getId()); + UnivApplyInfoDetailResponse secondResponse = univApplyInfoQueryService.getUnivApplyInfoDetail(괌대학_A_지원_정보.getId()); + + // then + assertThat(firstResponse).isEqualTo(secondResponse); + then(univApplyInfoRepository).should(times(1)).getUnivApplyInfoById(괌대학_A_지원_정보.getId()); + } + + @Test + void 존재하지_않는_대학_지원_정보를_조회하면_예외가_발생한다() { + // given + Long invalidUnivApplyInfoId = 9999L; + + // when & then + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> univApplyInfoQueryService.getUnivApplyInfoDetail(invalidUnivApplyInfoId)) + .havingRootCause() + .isInstanceOf(CustomException.class) + .withMessage(UNIV_APPLY_INFO_NOT_FOUND.getMessage()); + } + } + + @Nested + class 대학_지원_정보_필터링_검색 { + + @Test + void 어학_시험_종류로_필터링한다() { + // given + UnivApplyInfoFilterSearchRequest request = new UnivApplyInfoFilterSearchRequest(TOEIC, null, null); + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + languageRequirementFixture.토익_800(괌대학_A_지원_정보); + UnivApplyInfo 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보(); + languageRequirementFixture.토플_70(괌대학_B_지원_정보); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly(UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보)); + } + + @Test + void 어학_시험_점수가_기준치_이상인_곳을_필터링한다() { + // given + UnivApplyInfoFilterSearchRequest request = new UnivApplyInfoFilterSearchRequest(TOEIC, "800", null); + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + languageRequirementFixture.토익_800(괌대학_A_지원_정보); + UnivApplyInfo 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보(); + languageRequirementFixture.토익_900(괌대학_B_지원_정보); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly(UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보)); + } + + @Test + void 국가_코드로_필터링한다() { + // given + UnivApplyInfoFilterSearchRequest request1 = new UnivApplyInfoFilterSearchRequest(TOEIC, null, List.of("US")); + UnivApplyInfoFilterSearchRequest request2 = new UnivApplyInfoFilterSearchRequest(TOEIC, null, List.of("US", "CA")); + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + languageRequirementFixture.토익_800(괌대학_A_지원_정보); + UnivApplyInfo 메모리얼대학_세인트존스_A_지원_정보 = univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보(); + languageRequirementFixture.토익_800(메모리얼대학_세인트존스_A_지원_정보); + + // when + UnivApplyInfoPreviewResponses response1 = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request1, term); + UnivApplyInfoPreviewResponses response2 = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request2, term); + + // then + assertAll( + () -> assertThat(response1.univApplyInfoPreviews()) + .containsExactly(UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보)), + () -> assertThat(response2.univApplyInfoPreviews()) + .containsExactlyInAnyOrder( + UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보), + UnivApplyInfoPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보) + ) + ); + } + } + + @Nested + class 대학_지원_정보_텍스트_검색 { + + @Test + void 텍스트가_없으면_전체_대학을_id_순으로_정렬하여_반환한다() { + // given + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + UnivApplyInfo 메이지대학_지원_정보 = univApplyInfoFixture.메이지대학_지원_정보(); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(null, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly( + UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보), + UnivApplyInfoPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Nested + class 각각의_검색_대상에_대해_검색한다 { + + @Test + void 국문_대학_지원_정보명() { + // given + String text = "메"; + UnivApplyInfo 메이지대학_지원_정보 = univApplyInfoFixture.메이지대학_지원_정보(); + UnivApplyInfo 메모리얼대학_세인트존스_A_지원_정보 = univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보(); + univApplyInfoFixture.괌대학_A_지원_정보(); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly( + UnivApplyInfoPreviewResponse.from(메이지대학_지원_정보), + UnivApplyInfoPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보) + ); + } + + @Test + void 국문_국가명() { + // given + String text = "미국"; + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + UnivApplyInfo 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보(); + univApplyInfoFixture.메이지대학_지원_정보(); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly( + UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보), + UnivApplyInfoPreviewResponse.from(괌대학_B_지원_정보) + ); + } + + @Test + void 국문_권역명() { + // given + String text = "유럽"; + UnivApplyInfo 린츠_카톨릭대학_지원_정보 = univApplyInfoFixture.린츠_카톨릭대학_지원_정보(); + UnivApplyInfo 서던덴마크대학교_지원_정보 = univApplyInfoFixture.서던덴마크대학교_지원_정보(); + univApplyInfoFixture.메이지대학_지원_정보(); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly( + UnivApplyInfoPreviewResponse.from(린츠_카톨릭대학_지원_정보), + UnivApplyInfoPreviewResponse.from(서던덴마크대학교_지원_정보) + ); + } + } + + @Test + void 대학_국가_권역_일치_순서로_정렬하여_응답한다() { + // given + String text = "아"; + UnivApplyInfo 권역_아 = univApplyInfoFixture.메이지대학_지원_정보(); + UnivApplyInfo 국가_아 = univApplyInfoFixture.그라츠대학_지원_정보(); + UnivApplyInfo 대학지원정보_아 = univApplyInfoFixture.아칸소주립대학_지원_정보(); + + // when + UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term); + + // then + assertThat(response.univApplyInfoPreviews()) + .containsExactly( + UnivApplyInfoPreviewResponse.from(대학지원정보_아), + UnivApplyInfoPreviewResponse.from(국가_아), + UnivApplyInfoPreviewResponse.from(권역_아) + ); + } + + @Test + void 캐시가_적용된다() { + // given + String text = "Guam"; + UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + + // when + UnivApplyInfoPreviewResponses firstResponse = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term); + UnivApplyInfoPreviewResponses secondResponse = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term); + + // then + assertThatCode(() -> { + List firstResponseIds = extractIds(firstResponse); + List secondResponseIds = extractIds(secondResponse); + assertThat(firstResponseIds).isEqualTo(secondResponseIds); + }).doesNotThrowAnyException(); + then(univApplyInfoRepository).should(times(1)).findAllByText(text, term); + } + + private List extractIds(UnivApplyInfoPreviewResponses responses) { + return responses.univApplyInfoPreviews() + .stream() + .map(UnivApplyInfoPreviewResponse::id) + .toList(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java new file mode 100644 index 000000000..2c8dd954e --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UnivApplyInfoRecommendServiceTest.java @@ -0,0 +1,163 @@ +package com.example.solidconnection.university.service; + +import static com.example.solidconnection.university.service.UnivApplyInfoRecommendService.RECOMMEND_UNIV_APPLY_INFO_NUM; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.location.country.domain.InterestedCountry; +import com.example.solidconnection.location.country.fixture.CountryFixture; +import com.example.solidconnection.location.country.repository.InterestedCountryRepository; +import com.example.solidconnection.location.region.domain.InterestedRegion; +import com.example.solidconnection.location.region.fixture.RegionFixture; +import com.example.solidconnection.location.region.repository.InterestedRegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.dto.UnivApplyInfoPreviewResponse; +import com.example.solidconnection.university.dto.UnivApplyInfoRecommendsResponse; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("대학 지원 정보 추천 서비스 테스트") +class UnivApplyInfoRecommendServiceTest { + + @Autowired + private UnivApplyInfoRecommendService univApplyInfoRecommendService; + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private InterestedCountryRepository interestedCountryRepository; + + @Autowired + private GeneralUnivApplyInfoRecommendService generalUnivApplyInfoRecommendService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private RegionFixture regionFixture; + + @Autowired + private CountryFixture countryFixture; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + private SiteUser user; + private UnivApplyInfo 괌대학_A_지원_정보; + private UnivApplyInfo 괌대학_B_지원_정보; + private UnivApplyInfo 네바다주립대학_라스베이거스_지원_정보; + private UnivApplyInfo 메모리얼대학_세인트존스_A_지원_정보; + private UnivApplyInfo 서던덴마크대학교_지원_정보; + private UnivApplyInfo 코펜하겐IT대학_지원_정보; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(); + 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보(); + 네바다주립대학_라스베이거스_지원_정보 = univApplyInfoFixture.네바다주립대학_라스베이거스_지원_정보(); + 메모리얼대학_세인트존스_A_지원_정보 = univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보(); + 서던덴마크대학교_지원_정보 = univApplyInfoFixture.서던덴마크대학교_지원_정보(); + 코펜하겐IT대학_지원_정보 = univApplyInfoFixture.코펜하겐IT대학_지원_정보(); + univApplyInfoFixture.그라츠대학_지원_정보(); + univApplyInfoFixture.그라츠공과대학_지원_정보(); + univApplyInfoFixture.린츠_카톨릭대학_지원_정보(); + univApplyInfoFixture.메이지대학_지원_정보(); + generalUnivApplyInfoRecommendService.init(); + } + + @Test + void 관심_지역_설정한_사용자의_맞춤_추천_대학_지원_정보를_조회한다() { + // given + interestedRegionRepository.save(new InterestedRegion(user, regionFixture.영미권())); + + // when + UnivApplyInfoRecommendsResponse response = univApplyInfoRecommendService.getPersonalRecommends(user.getId()); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + .containsAll(List.of( + UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보), + UnivApplyInfoPreviewResponse.from(괌대학_B_지원_정보), + UnivApplyInfoPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UnivApplyInfoPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보) + )); + } + + @Test + void 관심_국가_설정한_사용자의_맞춤_추천_대학_지원_정보를_조회한다() { + // given + interestedCountryRepository.save(new InterestedCountry(user, countryFixture.덴마크())); + + // when + UnivApplyInfoRecommendsResponse response = univApplyInfoRecommendService.getPersonalRecommends(user.getId()); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + .containsAll(List.of( + UnivApplyInfoPreviewResponse.from(서던덴마크대학교_지원_정보), + UnivApplyInfoPreviewResponse.from(코펜하겐IT대학_지원_정보) + )); + } + + @Test + void 관심_지역과_국가_모두_설정한_사용자의_맞춤_추천_대학_지원_정보를_조회한다() { + // given + interestedRegionRepository.save(new InterestedRegion(user, regionFixture.영미권())); + interestedCountryRepository.save(new InterestedCountry(user, countryFixture.덴마크())); + + // when + UnivApplyInfoRecommendsResponse response = univApplyInfoRecommendService.getPersonalRecommends(user.getId()); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + .containsExactlyInAnyOrder( + UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보), + UnivApplyInfoPreviewResponse.from(괌대학_B_지원_정보), + UnivApplyInfoPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UnivApplyInfoPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UnivApplyInfoPreviewResponse.from(서던덴마크대학교_지원_정보), + UnivApplyInfoPreviewResponse.from(코펜하겐IT대학_지원_정보) + ); + } + + @Test + void 관심사_미설정_사용자는_일반_추천_대학_지원_정보를_조회한다() { + // when + UnivApplyInfoRecommendsResponse response = univApplyInfoRecommendService.getPersonalRecommends(user.getId()); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + .containsExactlyInAnyOrderElementsOf( + generalUnivApplyInfoRecommendService.getGeneralRecommends().stream() + .map(UnivApplyInfoPreviewResponse::from).toList() + ); + } + + @Test + void 일반_추천_대학_지원_정보를_조회한다() { + // when + UnivApplyInfoRecommendsResponse response = univApplyInfoRecommendService.getGeneralRecommends(); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIV_APPLY_INFO_NUM) + .containsExactlyInAnyOrderElementsOf( + generalUnivApplyInfoRecommendService.getGeneralRecommends().stream() + .map(UnivApplyInfoPreviewResponse::from) + .toList() + ); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java deleted file mode 100644 index 6bc3c13e3..000000000 --- a/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.university.domain.LikedUniversity; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.dto.IsLikeResponse; -import com.example.solidconnection.university.dto.LikeResultResponse; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import static com.example.solidconnection.custom.exception.ErrorCode.ALREADY_LIKED_UNIVERSITY; -import static com.example.solidconnection.custom.exception.ErrorCode.NOT_LIKED_UNIVERSITY; -import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; -import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_CANCELED_MESSAGE; -import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_SUCCESS_MESSAGE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - -@DisplayName("대학교 좋아요 서비스 테스트") -class UniversityLikeServiceTest extends BaseIntegrationTest { - - @Autowired - private UniversityLikeService universityLikeService; - - @Autowired - private LikedUniversityRepository likedUniversityRepository; - - @Autowired - private SiteUserRepository siteUserRepository; - - @Nested - class 대학_좋아요를_등록한다 { - - @Test - void 성공적으로_좋아요를_등록한다() { - // given - SiteUser testUser = createSiteUser(); - - // when - LikeResultResponse response = universityLikeService.likeUniversity(testUser, 괌대학_A_지원_정보.getId()); - - // then - assertAll( - () -> assertThat(response.result()).isEqualTo(LIKE_SUCCESS_MESSAGE), - () -> assertThat(likedUniversityRepository.findBySiteUserAndUniversityInfoForApply( - testUser, 괌대학_A_지원_정보 - )).isPresent() - ); - } - - @Test - void 이미_좋아요한_대학이면_예외_응답을_반환한다() { - // given - SiteUser testUser = createSiteUser(); - saveLikedUniversity(testUser, 괌대학_A_지원_정보); - - // when & then - assertThatCode(() -> universityLikeService.likeUniversity(testUser, 괌대학_A_지원_정보.getId())) - .isInstanceOf(CustomException.class) - .hasMessage(ALREADY_LIKED_UNIVERSITY.getMessage()); - } - } - - @Nested - class 대학_좋아요를_취소한다 { - - @Test - void 성공적으로_좋아요를_취소한다() { - // given - SiteUser testUser = createSiteUser(); - saveLikedUniversity(testUser, 괌대학_A_지원_정보); - - // when - LikeResultResponse response = universityLikeService.cancelLikeUniversity(testUser, 괌대학_A_지원_정보.getId()); - - // then - assertAll( - () -> assertThat(response.result()).isEqualTo(LIKE_CANCELED_MESSAGE), - () -> assertThat(likedUniversityRepository.findBySiteUserAndUniversityInfoForApply( - testUser, 괌대학_A_지원_정보 - )).isEmpty() - ); - } - - @Test - void 좋아요하지_않은_대학이면_예외_응답을_반환한다() { - // given - SiteUser testUser = createSiteUser(); - - // when & then - assertThatCode(() -> universityLikeService.cancelLikeUniversity(testUser, 괌대학_A_지원_정보.getId())) - .isInstanceOf(CustomException.class) - .hasMessage(NOT_LIKED_UNIVERSITY.getMessage()); - } - } - - @Test - void 존재하지_않는_대학_좋아요_시도하면_예외_응답을_반환한다() { - // given - SiteUser testUser = createSiteUser(); - Long invalidUniversityId = 9999L; - - // when & then - assertThatCode(() -> universityLikeService.likeUniversity(testUser, invalidUniversityId)) - .isInstanceOf(CustomException.class) - .hasMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); - } - - @Test - void 좋아요한_대학인지_확인한다() { - // given - SiteUser testUser = createSiteUser(); - saveLikedUniversity(testUser, 괌대학_A_지원_정보); - - // when - IsLikeResponse response = universityLikeService.getIsLiked(testUser, 괌대학_A_지원_정보.getId()); - - // then - assertThat(response.isLike()).isTrue(); - } - - @Test - void 좋아요하지_않은_대학인지_확인한다() { - // given - SiteUser testUser = createSiteUser(); - - // when - IsLikeResponse response = universityLikeService.getIsLiked(testUser, 괌대학_A_지원_정보.getId()); - - // then - assertThat(response.isLike()).isFalse(); - } - - @Test - void 존재하지_않는_대학의_좋아요_여부를_조회하면_예외_응답을_반환한다() { - // given - SiteUser testUser = createSiteUser(); - Long invalidUniversityId = 9999L; - - // when & then - assertThatCode(() -> universityLikeService.getIsLiked(testUser, invalidUniversityId)) - .isInstanceOf(CustomException.class) - .hasMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); - } - - private SiteUser createSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } - - private void saveLikedUniversity(SiteUser siteUser, UniversityInfoForApply universityInfoForApply) { - LikedUniversity likedUniversity = LikedUniversity.builder() - .siteUser(siteUser) - .universityInfoForApply(universityInfoForApply) - .build(); - likedUniversityRepository.save(likedUniversity); - } -} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java deleted file mode 100644 index 1cd0d755f..000000000 --- a/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.dto.UniversityDetailResponse; -import com.example.solidconnection.university.dto.LanguageRequirementResponse; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponses; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import com.example.solidconnection.university.repository.custom.UniversityFilterRepository; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; - -import java.util.List; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; -import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.times; - -@DisplayName("대학교 조회 서비스 테스트") -class UniversityQueryServiceTest extends BaseIntegrationTest { - - @Autowired - private UniversityQueryService universityQueryService; - - @SpyBean - private UniversityFilterRepository universityFilterRepository; - - @SpyBean - private UniversityInfoForApplyRepository universityInfoForApplyRepository; - - @Test - void 대학_상세정보를_정상_조회한다() { - // given - Long universityId = 괌대학_A_지원_정보.getId(); - - // when - UniversityDetailResponse response = universityQueryService.getUniversityDetail(universityId); - - // then - Assertions.assertAll( - () -> assertThat(response.id()).isEqualTo(괌대학_A_지원_정보.getId()), - () -> assertThat(response.term()).isEqualTo(괌대학_A_지원_정보.getTerm()), - () -> assertThat(response.koreanName()).isEqualTo(괌대학_A_지원_정보.getKoreanName()), - () -> assertThat(response.englishName()).isEqualTo(영미권_미국_괌대학.getEnglishName()), - () -> assertThat(response.formatName()).isEqualTo(영미권_미국_괌대학.getFormatName()), - () -> assertThat(response.region()).isEqualTo(영미권.getKoreanName()), - () -> assertThat(response.country()).isEqualTo(미국.getKoreanName()), - () -> assertThat(response.homepageUrl()).isEqualTo(영미권_미국_괌대학.getHomepageUrl()), - () -> assertThat(response.logoImageUrl()).isEqualTo(영미권_미국_괌대학.getLogoImageUrl()), - () -> assertThat(response.backgroundImageUrl()).isEqualTo(영미권_미국_괌대학.getBackgroundImageUrl()), - () -> assertThat(response.detailsForLocal()).isEqualTo(영미권_미국_괌대학.getDetailsForLocal()), - () -> assertThat(response.studentCapacity()).isEqualTo(괌대학_A_지원_정보.getStudentCapacity()), - () -> assertThat(response.tuitionFeeType()).isEqualTo(괌대학_A_지원_정보.getTuitionFeeType().getKoreanName()), - () -> assertThat(response.semesterAvailableForDispatch()).isEqualTo(괌대학_A_지원_정보.getSemesterAvailableForDispatch().getKoreanName()), - () -> assertThat(response.languageRequirements()).containsOnlyOnceElementsOf( - 괌대학_A_지원_정보.getLanguageRequirements().stream() - .map(LanguageRequirementResponse::from) - .toList()), - () -> assertThat(response.detailsForLanguage()).isEqualTo(괌대학_A_지원_정보.getDetailsForLanguage()), - () -> assertThat(response.gpaRequirement()).isEqualTo(괌대학_A_지원_정보.getGpaRequirement()), - () -> assertThat(response.gpaRequirementCriteria()).isEqualTo(괌대학_A_지원_정보.getGpaRequirementCriteria()), - () -> assertThat(response.semesterRequirement()).isEqualTo(괌대학_A_지원_정보.getSemesterRequirement()), - () -> assertThat(response.detailsForApply()).isEqualTo(괌대학_A_지원_정보.getDetailsForApply()), - () -> assertThat(response.detailsForMajor()).isEqualTo(괌대학_A_지원_정보.getDetailsForMajor()), - () -> assertThat(response.detailsForAccommodation()).isEqualTo(괌대학_A_지원_정보.getDetailsForAccommodation()), - () -> assertThat(response.detailsForEnglishCourse()).isEqualTo(괌대학_A_지원_정보.getDetailsForEnglishCourse()), - () -> assertThat(response.details()).isEqualTo(괌대학_A_지원_정보.getDetails()), - () -> assertThat(response.accommodationUrl()).isEqualTo(괌대학_A_지원_정보.getUniversity().getAccommodationUrl()), - () -> assertThat(response.englishCourseUrl()).isEqualTo(괌대학_A_지원_정보.getUniversity().getEnglishCourseUrl()) - ); - } - - @Test - void 대학_상세정보_조회시_캐시가_적용된다() { - // given - Long universityId = 괌대학_A_지원_정보.getId(); - - // when - UniversityDetailResponse firstResponse = universityQueryService.getUniversityDetail(universityId); - UniversityDetailResponse secondResponse = universityQueryService.getUniversityDetail(universityId); - - // then - assertThat(firstResponse).isEqualTo(secondResponse); - then(universityInfoForApplyRepository).should(times(1)).getUniversityInfoForApplyById(universityId); - } - - @Test - void 존재하지_않는_대학_상세정보를_조회하면_예외_응답을_반환한다() { - // given - Long invalidUniversityInfoForApplyId = 9999L; - - // when & then - assertThatExceptionOfType(RuntimeException.class) - .isThrownBy(() -> universityQueryService.getUniversityDetail(invalidUniversityInfoForApplyId)) - .havingRootCause() - .isInstanceOf(CustomException.class) - .withMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); - } - - @Test - void 전체_대학을_조회한다() { - // when - UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( - null, List.of(), null, null); - - // then - assertThat(response.universityInfoForApplyPreviewResponses()) - .containsExactlyInAnyOrder( - UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), - UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), - UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(린츠_카톨릭대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) - ); - } - - @Test - void 대학_조회시_캐시가_적용된다() { - // given - String regionCode = 영미권.getCode(); - List keywords = List.of("괌"); - LanguageTestType testType = LanguageTestType.TOEFL_IBT; - String testScore = "70"; - String term = "2024-1"; - - // when - UniversityInfoForApplyPreviewResponses firstResponse = - universityQueryService.searchUniversity(regionCode, keywords, testType, testScore); - UniversityInfoForApplyPreviewResponses secondResponse = - universityQueryService.searchUniversity(regionCode, keywords, testType, testScore); - - // then - assertThat(firstResponse).isEqualTo(secondResponse); - then(universityFilterRepository).should(times(1)) - .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( - regionCode, keywords, testType, testScore, term); - } - - @Test - void 지역으로_대학을_필터링한다() { - // when - UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( - 영미권.getCode(), List.of(), null, null); - - // then - assertThat(response.universityInfoForApplyPreviewResponses()) - .containsExactlyInAnyOrder( - UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), - UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보) - ); - } - - @Test - void 키워드로_대학을_필터링한다() { - // when - UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( - null, List.of("라", "일본"), null, null); - - // then - assertThat(response.universityInfoForApplyPreviewResponses()) - .containsExactlyInAnyOrder( - UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), - UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) - ); - } - - @Test - void 어학시험_조건으로_대학을_필터링한다() { - // when - UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( - null, List.of(), LanguageTestType.TOEFL_IBT, "70"); - - // then - assertThat(response.universityInfoForApplyPreviewResponses()) - .containsExactlyInAnyOrder( - UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), - UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보) - ); - } - - @Test - void 모든_조건으로_대학을_필터링한다() { - // when - UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( - "EUROPE", List.of(), LanguageTestType.TOEFL_IBT, "70"); - - // then - assertThat(response.universityInfoForApplyPreviewResponses()).containsExactly(UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보)); - } -} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java deleted file mode 100644 index 11591d11f..000000000 --- a/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.entity.InterestedCountry; -import com.example.solidconnection.entity.InterestedRegion; -import com.example.solidconnection.repositories.InterestedCountyRepository; -import com.example.solidconnection.repositories.InterestedRegionRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.support.integration.BaseIntegrationTest; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import com.example.solidconnection.university.dto.UniversityRecommendsResponse; -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 java.util.List; - -import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; -import static org.assertj.core.api.Assertions.assertThat; - -@DisplayName("대학교 추천 서비스 테스트") -class UniversityRecommendServiceTest extends BaseIntegrationTest { - - @Autowired - private UniversityRecommendService universityRecommendService; - - @Autowired - private SiteUserRepository siteUserRepository; - - @Autowired - private InterestedRegionRepository interestedRegionRepository; - - @Autowired - private InterestedCountyRepository interestedCountyRepository; - - @Autowired - private GeneralUniversityRecommendService generalUniversityRecommendService; - - @BeforeEach - void setUp() { - generalUniversityRecommendService.init(); - } - - @Test - void 관심_지역_설정한_사용자의_맞춤_추천_대학을_조회한다() { - // given - SiteUser testUser = createSiteUser(); - interestedRegionRepository.save(new InterestedRegion(testUser, 영미권)); - - // when - UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); - - // then - assertThat(response.recommendedUniversities()) - .hasSize(RECOMMEND_UNIVERSITY_NUM) - .containsAll(List.of( - UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보) - )); - } - - @Test - void 관심_국가_설정한_사용자의_맞춤_추천_대학을_조회한다() { - // given - SiteUser testUser = createSiteUser(); - interestedCountyRepository.save(new InterestedCountry(testUser, 덴마크)); - - // when - UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); - - // then - assertThat(response.recommendedUniversities()) - .hasSize(RECOMMEND_UNIVERSITY_NUM) - .containsAll(List.of( - UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), - UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) - )); - } - - @Test - void 관심_지역과_국가_모두_설정한_사용자의_맞춤_추천_대학을_조회한다() { - // given - SiteUser testUser = createSiteUser(); - interestedRegionRepository.save(new InterestedRegion(testUser, 영미권)); - interestedCountyRepository.save(new InterestedCountry(testUser, 덴마크)); - - // when - UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); - - // then - assertThat(response.recommendedUniversities()) - .hasSize(RECOMMEND_UNIVERSITY_NUM) - .containsExactlyInAnyOrder( - UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), - UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), - UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), - UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), - UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) - ); - } - - @Test - void 관심사_미설정_사용자는_일반_추천_대학을_조회한다() { - // given - SiteUser testUser = createSiteUser(); - - // when - UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); - - // then - assertThat(response.recommendedUniversities()) - .hasSize(RECOMMEND_UNIVERSITY_NUM) - .containsExactlyInAnyOrderElementsOf( - generalUniversityRecommendService.getRecommendUniversities().stream() - .map(UniversityInfoForApplyPreviewResponse::from) - .toList() - ); - } - - @Test - void 일반_추천_대학을_조회한다() { - // when - UniversityRecommendsResponse response = universityRecommendService.getGeneralRecommends(); - - // then - assertThat(response.recommendedUniversities()) - .hasSize(RECOMMEND_UNIVERSITY_NUM) - .containsExactlyInAnyOrderElementsOf( - generalUniversityRecommendService.getRecommendUniversities().stream() - .map(UniversityInfoForApplyPreviewResponse::from) - .toList() - ); - } - - private SiteUser createSiteUser() { - SiteUser siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - PreparationStatus.CONSIDERING, - Role.MENTEE - ); - return siteUserRepository.save(siteUser); - } -} diff --git a/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java deleted file mode 100644 index 95bdd5a52..000000000 --- a/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.example.solidconnection.util; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; - -import java.util.Date; - -import static com.example.solidconnection.util.JwtUtils.parseSubject; -import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; -import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertAll; - -@DisplayName("JwtUtils 테스트") -class JwtUtilsTest { - - private final String jwtSecretKey = "jwt-secret-key"; - - @Nested - class 요청으로부터_토큰을_추출한다 { - - @Test - void 토큰이_있으면_토큰을_반환한다() { - // given - MockHttpServletRequest request = new MockHttpServletRequest(); - String token = "token"; - request.addHeader("Authorization", "Bearer " + token); - - // when - String extractedToken = parseTokenFromRequest(request); - - // then - assertThat(extractedToken).isEqualTo(token); - } - - @Test - void 토큰이_없으면_null_을_반환한다() { - // given - MockHttpServletRequest noHeader = new MockHttpServletRequest(); - MockHttpServletRequest wrongPrefix = new MockHttpServletRequest(); - wrongPrefix.addHeader("Authorization", "Wrong token"); - MockHttpServletRequest emptyToken = new MockHttpServletRequest(); - wrongPrefix.addHeader("Authorization", "Bearer "); - - // when & then - assertAll( - () -> assertThat(parseTokenFromRequest(noHeader)).isNull(), - () -> assertThat(parseTokenFromRequest(wrongPrefix)).isNull(), - () -> assertThat(parseTokenFromRequest(emptyToken)).isNull() - ); - } - } - - @Nested - class 유효한_토큰으로부터_subject_를_추출한다 { - - @Test - void 유효한_토큰의_subject_를_추출한다() { - // given - String subject = "subject000"; - String token = createValidToken(subject); - - // when - String extractedSubject = parseSubject(token, jwtSecretKey); - - // then - assertThat(extractedSubject).isEqualTo(subject); - } - - @Test - void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { - // given - String subject = "subject123"; - String token = createExpiredToken(subject); - - // when - assertThatCode(() -> parseSubject(token, jwtSecretKey)) - .isInstanceOf(CustomException.class) - .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); - } - } - - @Nested - class 만료된_토큰으로부터_subject_를_추출한다 { - - @Test - void 만료된_토큰의_subject_를_예외를_발생시키지_않고_추출한다() { - // given - String subject = "subject999"; - String token = createExpiredToken(subject); - - // when - String extractedSubject = parseSubjectIgnoringExpiration(token, jwtSecretKey); - - // then - assertThat(extractedSubject).isEqualTo(subject); - } - - @Test - void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { - // given - String token = createExpiredUnsignedToken("hackers secret key"); - - // when & then - assertThatCode(() -> parseSubjectIgnoringExpiration(token, jwtSecretKey)) - .isInstanceOf(CustomException.class) - .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); - } - } - - - @Nested - class 토큰이_만료되었는지_확인한다 { - - @Test - void 서명된_토큰의_만료_여부를_반환한다() { - // given - String subject = "subject123"; - String validToken = createValidToken(subject); - String expiredToken = createExpiredToken(subject); - - // when - boolean isExpired1 = JwtUtils.isExpired(validToken, jwtSecretKey); - boolean isExpired2 = JwtUtils.isExpired(expiredToken, jwtSecretKey); - - // then - assertAll( - () -> assertThat(isExpired1).isFalse(), - () -> assertThat(isExpired2).isTrue() - ); - } - - @Test - void 서명되지_않은_토큰의_만료_여부를_반환한다() { - // given - String subject = "subject123"; - String validToken = createValidToken(subject); - String expiredToken = createExpiredToken(subject); - - // when - boolean isExpired1 = JwtUtils.isExpired(validToken, "wrong-secret-key"); - boolean isExpired2 = JwtUtils.isExpired(expiredToken, "wrong-secret-key"); - - // then - assertAll( - () -> assertThat(isExpired1).isTrue(), - () -> assertThat(isExpired2).isTrue() - ); - } - } - - private String createValidToken(String subject) { - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, jwtSecretKey) - .compact(); - } - - private String createExpiredToken(String subject) { - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() - 1000)) - .signWith(SignatureAlgorithm.HS256, jwtSecretKey) - .compact(); - } - - private String createExpiredUnsignedToken(String jwtSecretKey) { - return Jwts.builder() - .setSubject("subject") - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() - 1000)) - .signWith(SignatureAlgorithm.HS256, jwtSecretKey) - .compact(); - } -} diff --git a/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java b/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java new file mode 100644 index 000000000..b39c91ece --- /dev/null +++ b/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java @@ -0,0 +1,91 @@ +package com.example.solidconnection.websocket; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.solidconnection.auth.service.AccessToken; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.sockjs.client.SockJsClient; +import org.springframework.web.socket.sockjs.client.Transport; +import org.springframework.web.socket.sockjs.client.WebSocketTransport; + +@TestContainerSpringBootTest +@DisplayName("WebSocket/STOMP 통합 테스트") +class WebSocketStompIntegrationTest { + + @LocalServerPort + private int port; + private String url; + private WebSocketStompClient stompClient; + private StompSession stompSession; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserFixture siteUserFixture; + + @BeforeEach + void setUp() { + this.url = String.format("ws://localhost:%d/connect", port); + List transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); + this.stompClient = new WebSocketStompClient(new SockJsClient(transports)); + this.stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + } + + @AfterEach + void tearDown() { + if (this.stompSession != null && this.stompSession.isConnected()) { + this.stompSession.disconnect(); + } + } + + @Nested + class WebSocket_핸드셰이크_및_STOMP_세션_수립_테스트 { + + @Test + void 인증된_사용자는_핸드셰이크를_성공한다() throws Exception { + // given + SiteUser user = siteUserFixture.사용자(); + AccessToken accessToken = authTokenProvider.generateAccessToken(user); + String tokenUrl = url + "?token=" + accessToken.token(); + + // when + stompSession = stompClient.connectAsync(tokenUrl, new StompSessionHandlerAdapter() { + }).get(5, SECONDS); + + // then + assertThat(stompSession.isConnected()).isTrue(); + } + + @Test + void 인증되지_않은_사용자는_핸드셰이크를_실패한다() { + // when & then + assertThatThrownBy(() -> { + stompClient.connectAsync(url, new StompSessionHandlerAdapter() { + }).get(5, TimeUnit.SECONDS); + }).isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(HttpClientErrorException.Unauthorized.class); + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 7c6f83171..ce5a848cb 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -42,6 +42,18 @@ view: count: scheduling: delay: 3000 +websocket: + thread-pool: + inbound: + core-pool-size: 8 + max-pool-size: 16 + queue-capacity: 1000 + outbound: + core-pool-size: 8 + max-pool-size: 16 + heartbeat: + server-interval: 15000 + client-interval: 15000 oauth: apple: token-url: "https://appleid.apple.com/auth/token" @@ -71,3 +83,8 @@ jwt: cors: allowed-origins: - "http://localhost:8080" +news: + default-thumbnail-url: "default-thumbnail-url" +token: + refresh: + cookie-domain: "test.domain.com"