diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..d7b078a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: '' +assignees: '' + +--- + +### Issue 타입(하나 이상의 Issue 타입을 선택해주세요) +□ 기능 추가 +□ 기능 삭제 +☑ 버그 리포트 +□ 버그 수정 +□ 의존성, 환경 변수, 빌드 관련 코드 업데이트 + +### 상세 내용 +#### 어떤 버그인가요? +> 어떤 버그인지 간결하게 설명해주세요 + +#### 어떤 상황에서 발생한 버그인가요? +> (가능하면) Given-When-Then 형식으로 서술해주세요 + +#### 예상 결과 +> 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요 + +### 라벨 +- 예상 소요 시간: `E: 1h` +- 그룹: `client`, `server` +- 긴급도: `High`, `Middle`, `Low` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..3ed523c0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +### Issue 타입(하나 이상의 Issue 타입을 선택해주세요) +☑ 기능 추가 +□ 기능 삭제 +□ 버그 리포트 +□ 버그 수정 +□ 의존성, 환경 변수, 빌드 관련 코드 업데이트 + +### 상세 내용 +#### 어떤 기능인가요? +> 추가하려는 기능에 대해 간결하게 설명해주세요 + +#### 작업 상세 내용 +- [ ] TO DO + +### 예상 소요 시간 +-[] `0.5h` +-[] `1h` +-[] `1.5h` +-[] `2h` +-[] `2.5h` +-[] `3h` + +### 라벨 +- 예상 소요 시간: `E: 1h` +- 그룹: `client`, `server` +- 긴급도: `High`, `Middle`, `Low` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..377295fb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,34 @@ +### PR 타입(하나 이상의 PR 타입을 선택해주세요) +☑ 기능 추가 + +□ 기능 삭제 + +□ 버그 수정 + +□ 의존성, 환경 변수, 빌드 관련 코드 업데이트 + +
+ +### 반영 브랜치 +ex) feat/login -> dev + +
+ +### 변경 사항 +ex) 로그인 시, 구글 소셜 로그인 기능을 추가했습니다. + +
+ +### 테스트 결과 +ex) 베이스 브랜치에 포함되기 위한 코드는 모두 정상적으로 동작해야 합니다. 결과물에 대한 스크린샷, GIF, 혹은 라이브 데모가 가능하도록 샘플API를 첨부할 수도 있습니다. + +
+ +### 연관된 이슈 +ex) #이슈번호, #이슈번호 + +
+ +### 리뷰 요구사항(선택) +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 +> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..470bbfb1 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,78 @@ +name: Build and Deploy to EC2 + +on: + push: + branches: [ "production" ] +# pull_request: +# branches: [ "production" ] + +env: + AWS_REGION: ap-northeast-2 + AWS_S3_BUCKET: gitget-deploy-bucket2 + AWS_CODE_DEPLOY_APPLICATION: GitGet-Application + AWS_CODE_DEPLOY_GROUP: GitGet-Deployment-Group + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.ACTIONS_TOKEN }} + submodules: true + + - name: make application.yml + run: | + mkdir -p src/main/resources + cp GitGet-BACK-SECRET/main/resources/application.yml src/main/resources/ + cp GitGet-BACK-SECRET/main/resources/application-prod.yml src/main/resources/ + cp GitGet-BACK-SECRET/main/resources/application-common.yml src/main/resources/ + + mkdir -p src/test/resources + cp GitGet-BACK-SECRET/test/resources/application.yml src/test/resources/ + cp GitGet-BACK-SECRET/test/resources/application-test.yml src/test/resources/ + + echo "Main resources contents:" + ls -la src/main/resources/ + echo "Test resources contents:" + ls -la src/test/resources/ + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + - name: Build with Gradle and Test + run: ./gradlew build test + + - name: Make zip file + run: zip -r ./$GITHUB_SHA.zip . + shell: bash + + - name: AWS credential 설정 + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: ${{ env.AWS_REGION }} + aws-access-key-id: ${{ secrets.CICD_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.CICD_SECRET_KEY }} + + + - name: Upload to S3 + run: aws s3 cp --region ap-northeast-2 ./$GITHUB_SHA.zip s3://$AWS_S3_BUCKET/$GITHUB_SHA.zip + + - name: EC2에 배포 + run: aws deploy create-deployment --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} --deployment-config-name CodeDeployDefault.AllAtOnce --deployment-group-name ${{ env.AWS_CODE_DEPLOY_GROUP }} --s3-location bucket=$AWS_S3_BUCKET,key=$GITHUB_SHA.zip,bundleType=zip + + - name: action-slack (Slack notification after deploy) + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: Backend + fields: repo,commit,message,author + mention: here + if_mention: failure,cancelled + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: always() \ No newline at end of file diff --git a/.github/workflows/prTest.yml b/.github/workflows/prTest.yml new file mode 100644 index 00000000..6f8cf990 --- /dev/null +++ b/.github/workflows/prTest.yml @@ -0,0 +1,47 @@ +name: Run gradlew clean test when PR + +on: + pull_request: + branches: [ "main", "production" ] + +jobs: + PRTest: + runs-on: ubuntu-latest + permissions: write-all + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.ACTIONS_TOKEN }} + submodules: true + + - name: make application.yml + run: | + mkdir -p src/main/resources + cp GitGet-BACK-SECRET/main/resources/application.yml src/main/resources/ + cp GitGet-BACK-SECRET/main/resources/application-prod.yml src/main/resources/ + cp GitGet-BACK-SECRET/main/resources/application-common.yml src/main/resources/ + + mkdir -p src/test/resources + cp GitGet-BACK-SECRET/test/resources/application.yml src/test/resources/ + cp GitGet-BACK-SECRET/test/resources/application-test.yml src/test/resources/ + + echo "Main resources contents:" + ls -la src/main/resources/ + echo "Test resources contents:" + ls -la src/test/resources/ + + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + - name: Build and Test + run: ./gradlew clean test + + # Test 후 Report 생성 + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + junit_files: '**/build/test-results/test/TEST-*.xml' \ No newline at end of file diff --git a/todoffin/.gitignore b/.gitignore similarity index 90% rename from todoffin/.gitignore rename to .gitignore index d98e3516..f03837f2 100644 --- a/todoffin/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +@@ -0,0 +1,42 @@ HELP.md .gradle build/ @@ -39,4 +40,5 @@ out/ .vscode/ ### MAC ### -*.DS_Store \ No newline at end of file +*.DS_Store +src/main/generated/** diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..9fa3da91 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "GitGet-BACK-SECRET"] + path = GitGet-BACK-SECRET + url = https://github.com/TeamTheGenius/GitGet-BACK-SECRET diff --git a/GitGet-BACK-SECRET b/GitGet-BACK-SECRET new file mode 160000 index 00000000..5cbf067e --- /dev/null +++ b/GitGet-BACK-SECRET @@ -0,0 +1 @@ +Subproject commit 5cbf067e9af6779fe46703f5529056455d1b3ee4 diff --git a/README.md b/README.md new file mode 100644 index 00000000..876d4371 --- /dev/null +++ b/README.md @@ -0,0 +1,287 @@ +![image](https://github.com/TeamTheGenius/TeamTheGenius_Server/assets/95005061/9e508d5c-ff0d-4e82-95d0-3e2d1d6217c4) + +
+ +## :raising_hand_man: 프로젝트 소개 +🔥 GitGet은 챌린지 참여와 인증 활동을 통해 규칙적인 공부 습관을 도와주는 서비스입니다.

+🙋🏻‍♂️Github 계정 연동을 통해 챌린지 활동을 인증할 수 있으며, 다른 참여자들의 인증 현황을 조회할 수 있습니다.

+🎯 챌린지에 설정되어 있는 목표 달성 시 포인트가 주어지며, 이를 통해 아이템을 구매하고 사용할 수 있습니다.


+ + +## :desktop_computer: 기술 스택 +Framework - + +ORM - + +Authorization - + +Test - + +Database - + +DevOps - + +Monitoring - + +Other - + +

+ +## 개발 환경 +``` +Java : 17 +Spring Boot : 3.2.1 +build : gradle +``` + +

+ +## 다운로드 방법 + +``` +git clone https://github.com/TeamTheGenius/TeamTheGenius_Server.git +``` + +

+ +## 화면 설계서 +![image](https://github.com/TeamTheGenius/TeamTheGenius_Server/assets/95005061/52d8e894-14a7-40ad-b94c-3af1a03fedd9) + +

+ +## 주요 기능 +### 로그인 / Github 연동 +* 사용자는 회원가입을 통해 서비스를 이용할 수 있습니다. +* 챌린지에 참여하기 위해서는 Github Access Token 인증 과정을 **필수**로 진행해야 합니다. +* Pull Request 작업으로 사용자 Repository와 서비스가 연결되었는 지 확인이 필요합니다. 참여하고자 하는 브랜치에서 아무 작업을 진행하고, PR을 등록하여 등록 여부를 확인해주세요. + +
+ +![image](https://github.com/TeamTheGenius/TeamTheGenius_Server/assets/95005061/019ad04b-b223-45f8-be21-a3a2bf63c0f2) + +
+ +### 홈 화면 +* 사용자는 참여하고자 하는 챌린지를 둘러볼 수 있습니다. 인기, 신규, 추천 카테고리를 이용 가능합니다. +* 검색을 통해 종료된 챌린지, 진행 중인 챌린지, 참여가 가능한 챌린지 목록을 확인할 수 있습니다. + +
+ +![image](https://github.com/TeamTheGenius/TeamTheGenius_Server/assets/95005061/45a77dd6-9cf4-40b7-8935-4ca7a93dbec3) + +
+ +### 챌린지 인증 현황 +* 참가자 인증 현황을 클릭하면 본인을 포함한 다른 참여자들의 인증 현황을 일주일 단위로 조회할 수 있습니다. +* 인증 내역을 확인하고 싶은 일자를 선택하면 그 날의 인증에 사용된 Github PR 목록 조회가 가능합니다. +* 조회한 Github PR 목록 중 구경하고 싶은 PR이 있다면, 해당 링크를 눌러 이동이 가능합니다. + +
+ +![image](https://github.com/TeamTheGenius/TeamTheGenius_Server/assets/95005061/9e83841d-0709-4339-bd5e-9e8e29e731c4) + +

+ +## 배포 플로우 +![image](https://github.com/TeamTheGenius/TeamTheGenius_Server/assets/95005061/8612cb5b-67de-4bf6-ad4e-40a46cafdadc) + +

+ +## 데이터베이스 +![image](https://github.com/kimdozzi/Java/assets/95005061/b8930f51-22b4-4574-b5f3-58ffd9bbac01) + +

+ +## 아키텍처 +```bash +. +├── main +│   ├── java +│   │   └── com +│   │   └── genius +│   │   └── gitget +│   │   ├── admin +│   │   │   ├── signout +│   │   │   └── topic +│   │   │   ├── controller +│   │   │   ├── domain +│   │   │   ├── dto +│   │   │   ├── repository +│   │   │   └── service +│   │   ├── challenge +│   │   │   ├── certification +│   │   │   │   ├── controller +│   │   │   │   ├── domain +│   │   │   │   ├── dto +│   │   │   │   │   └── github +│   │   │   │   ├── repository +│   │   │   │   ├── service +│   │   │   │   └── util +│   │   │   ├── instance +│   │   │   │   ├── controller +│   │   │   │   ├── domain +│   │   │   │   ├── dto +│   │   │   │   │   ├── crud +│   │   │   │   │   ├── detail +│   │   │   │   │   ├── home +│   │   │   │   │   └── search +│   │   │   │   ├── repository +│   │   │   │   └── service +│   │   │   ├── likes +│   │   │   │   ├── controller +│   │   │   │   ├── domain +│   │   │   │   ├── dto +│   │   │   │   ├── repository +│   │   │   │   └── service +│   │   │   ├── myChallenge +│   │   │   │   ├── controller +│   │   │   │   ├── dto +│   │   │   │   └── service +│   │   │   ├── participant +│   │   │   │   ├── domain +│   │   │   │   ├── repository +│   │   │   │   └── service +│   │   │   ├── report +│   │   │   │   ├── controller +│   │   │   │   ├── domain +│   │   │   │   ├── dto +│   │   │   │   ├── repository +│   │   │   │   └── service +│   │   │   └── user +│   │   │   ├── controller +│   │   │   ├── domain +│   │   │   ├── dto +│   │   │   ├── repository +│   │   │   └── service +│   │   ├── global +│   │   │   ├── file +│   │   │   │   ├── controller +│   │   │   │   ├── domain +│   │   │   │   ├── dto +│   │   │   │   ├── repository +│   │   │   │   └── service +│   │   │   ├── security +│   │   │   │   ├── config +│   │   │   │   ├── constants +│   │   │   │   ├── controller +│   │   │   │   ├── domain +│   │   │   │   ├── dto +│   │   │   │   ├── filter +│   │   │   │   ├── handler +│   │   │   │   ├── info +│   │   │   │   │   └── impl +│   │   │   │   ├── repository +│   │   │   │   └── service +│   │   │   └── util +│   │   │   ├── config +│   │   │   ├── domain +│   │   │   ├── exception +│   │   │   ├── formatter +│   │   │   └── response +│   │   │   └── dto +│   │   ├── profile +│   │   │   ├── controller +│   │   │   ├── dto +│   │   │   └── service +│   │   ├── schedule +│   │   │   ├── controller +│   │   │   └── service +│   │   └── store +│   │   ├── item +│   │   │   ├── controller +│   │   │   ├── domain +│   │   │   ├── dto +│   │   │   ├── repository +│   │   │   └── service +│   │   └── payment +│   │   ├── config +│   │   ├── controller +│   │   ├── domain +│   │   ├── dto +│   │   ├── repository +│   │   └── service +│   └── resources +└── test + ├── java + │   └── com + │   └── genius + │   └── gitget + │   ├── admin + │   │   └── topic + │   │   ├── controller + │   │   ├── repository + │   │   └── service + │   ├── challenge + │   │   ├── certification + │   │   │   ├── controller + │   │   │   ├── repository + │   │   │   ├── service + │   │   │   └── util + │   │   ├── home + │   │   │   ├── controller + │   │   │   └── service + │   │   ├── instance + │   │   │   ├── controller + │   │   │   ├── repository + │   │   │   └── service + │   │   ├── item + │   │   │   └── service + │   │   ├── likes + │   │   │   ├── controller + │   │   │   └── service + │   │   ├── myChallenge + │   │   │   └── service + │   │   ├── participant + │   │   │   └── service + │   │   └── user + │   │   ├── controller + │   │   ├── domain + │   │   ├── repository + │   │   └── service + │   ├── global + │   │   ├── file + │   │   │   ├── domain + │   │   │   ├── repository + │   │   │   └── service + │   │   └── security + │   │   ├── config + │   │   ├── controller + │   │   └── service + │   ├── payment + │   │   ├── controller + │   │   └── service + │   ├── profile + │   │   ├── controller + │   │   └── service + │   └── util + │   └── file + └── resources + +``` + +

+ +## 기여자 + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 00000000..8c60e1c8 --- /dev/null +++ b/appspec.yml @@ -0,0 +1,17 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/app + overwrite: yes + +permissions: + - object: / + owner: ubuntu + group: ubuntu + +hooks: + ApplicationStart: + - location: scripts/deploy.sh + timeout: 60 \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..b8c91f7f --- /dev/null +++ b/build.gradle @@ -0,0 +1,93 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.1' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.genius' +version = '0.0.1-SNAPSHOT' + new Date().format("yyyyMMddHHmmss") + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + + // AWS + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.1.RELEASE' + + // json + implementation 'com.googlecode.json-simple:json-simple:1.1.1' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + // query log 띄우기 시작 + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + // query log 띄우기 끝 + + implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + //JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // MongoDB + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + // H2 + testRuntimeOnly 'com.h2database:h2:2.2.222' + + // Github API for Java + implementation 'org.kohsuke:github-api:1.318' + + // Slack + implementation 'com.slack.api:slack-api-client:1.25.1' + + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' +} + +bootJar { + archiveBaseName = 'GitGetApplication' + archiveFileName = 'GitGetApplication.jar' + archiveVersion = "0.0.1" +} + +jar { + enabled = false +} + +tasks.named('test') { + useJUnitPlatform() +} + +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} \ No newline at end of file diff --git a/todoffin/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from todoffin/gradle/wrapper/gradle-wrapper.jar rename to gradle/wrapper/gradle-wrapper.jar diff --git a/todoffin/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from todoffin/gradle/wrapper/gradle-wrapper.properties rename to gradle/wrapper/gradle-wrapper.properties diff --git a/todoffin/gradlew b/gradlew similarity index 100% rename from todoffin/gradlew rename to gradlew diff --git a/todoffin/gradlew.bat b/gradlew.bat similarity index 100% rename from todoffin/gradlew.bat rename to gradlew.bat diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 00000000..cf21924c --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,42 @@ +##!/bin/bash + +BUILD_JAR=$(ls /home/ubuntu/app/build/libs/*.jar) +JAR_NAME=$(basename $BUILD_JAR) +echo ">>> build 파일명: $JAR_NAME" >> /home/ubuntu/deploy.log + +echo ">>> build 파일 복사" >> /home/ubuntu/deploy.log +DEPLOY_PATH=/home/ubuntu/app/ +cp $BUILD_JAR $DEPLOY_PATH + +echo ">>> 현재 실행중인 애플리케이션 pid 확인 후 일괄 종료" >> /home/ubuntu/deploy.log +sudo ps -ef | grep java | awk '{print $2}' | xargs kill -15 + +DEPLOY_JAR=$DEPLOY_PATH$JAR_NAME +echo ">>> DEPLOY_JAR 배포" >> /home/ubuntu/deploy.log +echo ">>> $DEPLOY_JAR의 $JAR_NAME를 실행합니다" >> /home/ubuntu/deploy.log +nohup java -jar $DEPLOY_JAR >> /home/ubuntu/deploy.log 2> /home/ubuntu/deploy_err.log & + +## backup +##BUILD_JAR=$(ls /home/ubuntu/app/build/libs/*.jar) +##JAR_NAME=$(basename $BUILD_JAR) +##echo ">>> build 파일명: $JAR_NAME" >> /home/ubuntu/deploy.log +## +##echo ">>> build 파일 복사" >> /home/ubuntu/deploy.log +##DEPLOY_PATH=/home/ubuntu/ +##cp $BUILD_JAR $DEPLOY_PATH +## +##echo ">>> 현재 실행중인 애플리케이션 pid 확인" >> /home/ubuntu/deploy.log +##CURRENT_PID=$(pgrep -f $JAR_NAME) +## +##if [ -z $CURRENT_PID ] +##then +## echo ">>> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." >> /home/ubuntu/deploy.log +##else +## echo ">>> kill -15 $CURRENT_PID" +## kill -15 $CURRENT_PID +## sleep 5 +##fi +## +##DEPLOY_JAR=$DEPLOY_PATH$JAR_NAME +##echo ">>> DEPLOY_JAR 배포" >> /home/ubuntu/deploy.log +##nohup java -jar $DEPLOY_JAR >> /home/ubuntu/deploy.log 2> /home/ubuntu/deploy_err.log & \ No newline at end of file diff --git a/scripts/health.sh b/scripts/health.sh new file mode 100644 index 00000000..e9539eb8 --- /dev/null +++ b/scripts/health.sh @@ -0,0 +1,42 @@ +# health_check.sh + +#!/bin/bash + +# Crawl current connected port of WAS +CURRENT_PORT=$(cat /home/ubuntu/service_url.inc | grep -Po '[0-9]+' | tail -1) +TARGET_PORT=0 + +# Toggle port Number +if [ ${CURRENT_PORT} -eq 8081 ]; then + TARGET_PORT=8082 +elif [ ${CURRENT_PORT} -eq 8082 ]; then + TARGET_PORT=8081 +else + echo "> No WAS is connected to nginx" + exit 1 +fi + +echo "> Start health check of WAS at http://localhost:${TARGET_PORT}/api/auth/health-check ..." + +for RETRY_COUNT in 1 2 3 4 5 6 7 8 9 10 +do + echo "> #${RETRY_COUNT} trying..." + RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${TARGET_PORT}/api/auth/health-check) + + if [ ${RESPONSE_CODE} -eq 200 ]; then + echo "> New WAS successfully running" + exit 0 + elif [ ${RETRY_COUNT} -eq 10 ]; then + echo "> Health check failed." + exit 1 + fi + sleep 10 + +# if [ ${CURRENT_PORT} -eq ${TARGET_PORT} ]; then +# echo "> Health Check Failed." +# exit 1 +# else +# echo "> New WAS Successfully running." +# exit 0 +# fi +done diff --git a/scripts/run_new_was.sh b/scripts/run_new_was.sh new file mode 100644 index 00000000..545bbccb --- /dev/null +++ b/scripts/run_new_was.sh @@ -0,0 +1,30 @@ +# run_new_was.sh + +#!/bin/bash + +# 현재 포트 읽어오기 +CURRENT_PORT=$(cat /home/ubuntu/service_url.inc | grep -Po '[0-9]+' | tail -1) +TARGET_PORT=0 + +echo "> Current port of running WAS is ${CURRENT_PORT}." + +# 현재 포트가 8081이면 새로 WAS를 띄울 타겟 포트는 8082, 반대라면 8081 +if [ ${CURRENT_PORT} -eq 8081 ]; then + TARGET_PORT=8082 +elif [ ${CURRENT_PORT} -eq 8082 ]; then + TARGET_PORT=8081 +else + echo "> No WAS is connected to nginx" +fi + +TARGET_PID=$(lsof -Fp -i TCP:${TARGET_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+') + +# 만약 타겟 포트에도 WAS가 떠있다면, kill하고 새롭게 WAS를 띄움 +if [ ! -z ${TARGET_PID} ]; then + echo "> Kill WAS running at ${TARGET_PORT}." + sudo kill ${TARGET_PID} +fi + +nohup java -jar -Dserver.port=${TARGET_PORT} /home/ubuntu/app/build/libs/* > /home/ubuntu/nohup.out 2>&1 & +echo "> Now new WAS runs at ${TARGET_PORT}." +exit 0 diff --git a/scripts/switch.sh b/scripts/switch.sh new file mode 100644 index 00000000..e6c89184 --- /dev/null +++ b/scripts/switch.sh @@ -0,0 +1,29 @@ +# switch.sh + +#!/bin/bash + +# Crawl current connected port of WAS +CURRENT_PORT=$(cat /home/ubuntu/service_url.inc | grep -Po '[0-9]+' | tail -1) +TARGET_PORT=0 + +echo "> Nginx currently proxies to ${CURRENT_PORT}." + +# Toggle port number +if [ ${CURRENT_PORT} -eq 8081 ]; then + TARGET_PORT=8082 +elif [ ${CURRENT_PORT} -eq 8082 ]; then + TARGET_PORT=8081 +else + echo "> No WAS is connected to nginx" + exit 1 +fi + +# Change proxying port into target port +echo "set \$service_url http://127.0.0.1:${TARGET_PORT};" | tee /home/ubuntu/service_url.inc + +echo "> Now Nginx proxies to ${TARGET_PORT}." + +# Reload nginx +sudo service nginx reload + +echo "> Nginx reloaded." diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..d94797e2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'gitget' diff --git a/src/main/java/com/genius/gitget/GitgetApplication.java b/src/main/java/com/genius/gitget/GitgetApplication.java new file mode 100644 index 00000000..20dc5e46 --- /dev/null +++ b/src/main/java/com/genius/gitget/GitgetApplication.java @@ -0,0 +1,17 @@ +package com.genius.gitget; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +@EnableJpaAuditing +@EnableMongoRepositories +public class GitgetApplication { + public static void main(String[] args) { + SpringApplication.run(GitgetApplication.class, args); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/controller/CertificationController.java b/src/main/java/com/genius/gitget/challenge/certification/controller/CertificationController.java new file mode 100644 index 00000000..79842ad0 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/controller/CertificationController.java @@ -0,0 +1,149 @@ +package com.genius.gitget.challenge.certification.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.dto.CertificationInformation; +import com.genius.gitget.challenge.certification.dto.CertificationRequest; +import com.genius.gitget.challenge.certification.dto.CertificationResponse; +import com.genius.gitget.challenge.certification.dto.InstancePreviewResponse; +import com.genius.gitget.challenge.certification.dto.TotalResponse; +import com.genius.gitget.challenge.certification.dto.WeekResponse; +import com.genius.gitget.challenge.certification.facade.CertificationFacade; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.service.InstanceService; +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.service.ParticipantService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import com.genius.gitget.global.util.response.dto.SlicingResponse; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +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.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; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/certification") +public class CertificationController { + private final UserService userService; + private final InstanceService instanceService; + private final CertificationFacade certificationFacade; + private final ParticipantService participantService; + + + @GetMapping("/{instanceId}") + public ResponseEntity> getInstanceInformation( + @PathVariable Long instanceId + ) { + InstancePreviewResponse instancePreviewResponse = certificationFacade.getInstancePreview(instanceId); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), instancePreviewResponse) + ); + } + + @PostMapping("/today") + public ResponseEntity> certificateByGithub( + @GitGetUser User user, + @RequestBody CertificationRequest certificationRequest + ) { + CertificationResponse certificationResponse = certificationFacade.updateCertification( + user, certificationRequest); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), certificationResponse) + ); + } + + @PostMapping("/pass") + public ResponseEntity> passCertification( + @GitGetUser User user, + @RequestBody CertificationRequest certificationRequest + ) { + ActivatedResponse activatedResponse = certificationFacade.passCertification( + user.getId(), certificationRequest); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), activatedResponse) + ); + } + + @GetMapping("/week/{instanceId}") + public ResponseEntity> getWeekCertification( + @GitGetUser User user, + @PathVariable Long instanceId + ) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + Participant participant = participantService.findByJoinInfo(user.getId(), instanceId); + WeekResponse weekResponse = certificationFacade.getMyWeekCertifications(participant.getId(), kstDate); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), weekResponse) + ); + } + + @GetMapping("/week/all/{instanceId}") + public ResponseEntity> getAllUserWeekCertification( + @GitGetUser User user, + @PathVariable Long instanceId, + @PageableDefault Pageable pageable + ) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + Slice certifications = certificationFacade.getOthersWeekCertifications( + user.getId(), instanceId, kstDate, pageable); + + return ResponseEntity.ok().body( + new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), certifications) + ); + } + + @GetMapping("/total/{instanceId}") + public ResponseEntity> getTotalCertifications( + @PathVariable Long instanceId, + @RequestParam Long userId + ) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + User user = userService.findUserById(userId); + Participant participant = participantService.findByJoinInfo(user.getId(), instanceId); + TotalResponse totalResponse = certificationFacade.getTotalCertification( + participant.getId(), kstDate); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), totalResponse) + ); + } + + @GetMapping("/information/{instanceId}") + public ResponseEntity> getCertificationInformation( + @GitGetUser User user, + @PathVariable Long instanceId + ) { + + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + Instance instance = instanceService.findInstanceById(instanceId); + Participant participant = participantService.findByJoinInfo(user.getId(), instanceId); + + CertificationInformation certificationInformation = certificationFacade.getCertificationInformation( + instance, participant, kstDate); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), certificationInformation) + ); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/controller/GithubController.java b/src/main/java/com/genius/gitget/challenge/certification/controller/GithubController.java new file mode 100644 index 00000000..750f11db --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/controller/GithubController.java @@ -0,0 +1,93 @@ +package com.genius.gitget.challenge.certification.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.dto.github.GithubTokenRequest; +import com.genius.gitget.challenge.certification.dto.github.PullRequestResponse; +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.ListResponse; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/certification") +public class GithubController { + private final GithubFacade githubFacade; + + @PostMapping("/register/token") + public ResponseEntity registerGithubToken( + @GitGetUser User user, + @RequestBody GithubTokenRequest githubTokenRequest + ) { + githubFacade.registerGithubPersonalToken(user, githubTokenRequest.githubToken()); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } + + @GetMapping("/repositories") + public ResponseEntity> getPublicRepositories( + @GitGetUser User user + ) { + List repositories = githubFacade.getPublicRepositories(user); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), repositories) + ); + } + + @GetMapping("/verify/token") + public ResponseEntity verifyGithubToken( + @GitGetUser User user + ) { + githubFacade.verifyGithubToken(user); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } + + @GetMapping("/verify/repository") + public ResponseEntity verifyRepository( + @GitGetUser User user, + @RequestParam String repo + ) { + + githubFacade.verifyRepository(user, repo); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } + + @GetMapping("/verify/pull-request") + public ResponseEntity> verifyPullRequest( + @GitGetUser User user, + @RequestParam String repo + ) { + + List pullRequestResponses = githubFacade.verifyPullRequest( + user, repo, DateUtil.convertToKST(LocalDateTime.now()) + ); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), pullRequestResponses) + ); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/domain/CertificateStatus.java b/src/main/java/com/genius/gitget/challenge/certification/domain/CertificateStatus.java new file mode 100644 index 00000000..073411b0 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/domain/CertificateStatus.java @@ -0,0 +1,14 @@ +package com.genius.gitget.challenge.certification.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CertificateStatus { + NOT_YET("인증하기"), + CERTIFICATED("인증 갱신"), + PASSED("패스 완료"); + + private final String tag; +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/domain/Certification.java b/src/main/java/com/genius/gitget/challenge/certification/domain/Certification.java new file mode 100644 index 00000000..9248f88d --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/domain/Certification.java @@ -0,0 +1,113 @@ +package com.genius.gitget.challenge.certification.domain; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.PASSED; + +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.global.util.domain.BaseTimeEntity; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +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.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@Table(indexes = { + @Index( + name = "idx_participant_cert_attempt", + columnList = "participant_id, certificated_at, current_attempt DESC" + ) +}) +public class Certification extends BaseTimeEntity { + @Id + @Column(name = "certification_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "participant_id") + private Participant participant; + + private int currentAttempt; + + private LocalDate certificatedAt; + + private String certificationLinks; + + @Enumerated(value = EnumType.STRING) + @ColumnDefault("'NOT_YET'") + private CertificateStatus certificationStatus; + + + @Builder + public Certification(int currentAttempt, LocalDate certificatedAt, String certificationLinks, + CertificateStatus certificationStatus) { + this.currentAttempt = currentAttempt; + this.certificatedAt = certificatedAt; + this.certificationLinks = certificationLinks; + this.certificationStatus = certificationStatus; + } + + public static Certification of(CertificateStatus status, int currentAttempt, LocalDate certificatedAt) { + return Certification.builder() + .certificatedAt(certificatedAt) + .currentAttempt(currentAttempt) + .certificationStatus(status) + .certificationLinks("") + .build(); + } + + + //=== 비지니스 로직 ===// + public void update(LocalDate certificatedAt, CertificateStatus status, String certificationLinks) { + this.certificatedAt = certificatedAt; + this.certificationStatus = status; + this.certificationLinks = certificationLinks; + } + + public void updateToPass(LocalDate certificatedAt) { + this.certificatedAt = certificatedAt; + this.certificationStatus = CertificateStatus.PASSED; + this.certificationLinks = ""; + } + + public void validatePassCondition() { + if (this.certificationStatus != NOT_YET) { + throw new BusinessException(ErrorCode.CAN_NOT_USE_PASS_ITEM); + } + } + + public void validateCertificateCondition() { + if (this.getCertificationStatus() == PASSED) { + throw new BusinessException(ErrorCode.ALREADY_PASSED_CERTIFICATION); + } + } + + + //=== 연관관계 편의 메서드 ===// + public void setParticipant(Participant participant) { + this.participant = participant; + if (!participant.getCertificationList().contains(this)) { + participant.getCertificationList().add(this); + } + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationInformation.java b/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationInformation.java new file mode 100644 index 00000000..41cd3e0a --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationInformation.java @@ -0,0 +1,17 @@ +package com.genius.gitget.challenge.certification.dto; + +import lombok.Builder; + +@Builder +public record CertificationInformation( + String prTemplate, + String repository, + double successPercent, + int totalAttempt, + int currentAttempt, + int pointPerPerson, + int successCount, + int failureCount, + int remainCount +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationRequest.java b/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationRequest.java new file mode 100644 index 00000000..39381728 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationRequest.java @@ -0,0 +1,15 @@ +package com.genius.gitget.challenge.certification.dto; + +import java.time.LocalDate; +import lombok.Builder; + +@Builder +public record CertificationRequest( + Long instanceId, + LocalDate targetDate +) { + + public static CertificationRequest of(Long instanceId, LocalDate targetDate) { + return new CertificationRequest(instanceId, targetDate); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationResponse.java b/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationResponse.java new file mode 100644 index 00000000..9077b17d --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/CertificationResponse.java @@ -0,0 +1,58 @@ +package com.genius.gitget.challenge.certification.dto; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.Builder; + +@Builder +public record CertificationResponse( + Long certificationId, + int certificationAttempt, + DayOfWeek dayOfWeek, + LocalDate certificatedAt, + CertificateStatus certificateStatus, + int prCount, + List prLinks +) { + + + public static CertificationResponse createNonExist(int certificationAttempt, LocalDate certificatedAt) { + return CertificationResponse.builder() + .certificationId(0L) + .certificationAttempt(certificationAttempt) + .dayOfWeek(certificatedAt.getDayOfWeek()) + .certificatedAt(certificatedAt) + .certificateStatus(NOT_YET) + .prLinks(null) + .prCount(0) + .build(); + } + + public static CertificationResponse createExist(Certification certification) { + List prLinks = getPrList(certification.getCertificationLinks()); + + return CertificationResponse.builder() + .certificationId(certification.getId()) + .certificationAttempt(certification.getCurrentAttempt()) + .dayOfWeek(certification.getCertificatedAt().getDayOfWeek()) + .certificatedAt(certification.getCertificatedAt()) + .certificateStatus(certification.getCertificationStatus()) + .prLinks(prLinks) + .prCount(prLinks.size()) + .build(); + } + + private static List getPrList(String prLink) { + List prLinkList = List.of(prLink.split(",")); + if (prLinkList.size() == 1 && prLinkList.get(0).isEmpty()) { + return new ArrayList<>(); + } + return prLinkList; + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/InstancePreviewResponse.java b/src/main/java/com/genius/gitget/challenge/certification/dto/InstancePreviewResponse.java new file mode 100644 index 00000000..22899072 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/InstancePreviewResponse.java @@ -0,0 +1,32 @@ +package com.genius.gitget.challenge.certification.dto; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.dto.FileResponse; +import java.time.LocalDate; +import lombok.Builder; + +@Builder +public record InstancePreviewResponse( + Long instanceId, + String title, + int participantCount, + LocalDate startDate, + LocalDate completedDate, + int pointPerPerson, + String certificationMethod, + FileResponse fileResponse +) { + + public static InstancePreviewResponse createByEntity(Instance instance, FileResponse fileResponse) { + return InstancePreviewResponse.builder() + .instanceId(instance.getId()) + .title(instance.getTitle()) + .participantCount(instance.getParticipantCount()) + .startDate(instance.getStartedDate().toLocalDate()) + .completedDate(instance.getCompletedDate().toLocalDate()) + .pointPerPerson(instance.getPointPerPerson()) + .certificationMethod(instance.getCertificationMethod()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/TotalResponse.java b/src/main/java/com/genius/gitget/challenge/certification/dto/TotalResponse.java new file mode 100644 index 00000000..0311248e --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/TotalResponse.java @@ -0,0 +1,11 @@ +package com.genius.gitget.challenge.certification.dto; + +import java.util.List; +import lombok.Builder; + +@Builder +public record TotalResponse( + int totalAttempts, + List certifications +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/WeekResponse.java b/src/main/java/com/genius/gitget/challenge/certification/dto/WeekResponse.java new file mode 100644 index 00000000..e8140cde --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/WeekResponse.java @@ -0,0 +1,27 @@ +package com.genius.gitget.challenge.certification.dto; + +import com.genius.gitget.challenge.user.dto.UserProfileInfo; +import com.genius.gitget.global.file.dto.FileResponse; +import java.util.List; +import lombok.Builder; + +@Builder +public record WeekResponse( + Long userId, + String nickname, + Long frameId, + List certifications, + FileResponse profile + +) { + public static WeekResponse create(UserProfileInfo userProfileInfo, + List certifications) { + return WeekResponse.builder() + .userId(userProfileInfo.userId()) + .nickname(userProfileInfo.nickname()) + .frameId(userProfileInfo.frameId()) + .certifications(certifications) + .profile(userProfileInfo.fileResponse()) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/github/GithubTokenRequest.java b/src/main/java/com/genius/gitget/challenge/certification/dto/github/GithubTokenRequest.java new file mode 100644 index 00000000..f028f0d3 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/github/GithubTokenRequest.java @@ -0,0 +1,6 @@ +package com.genius.gitget.challenge.certification.dto.github; + +public record GithubTokenRequest( + String githubToken +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/dto/github/PullRequestResponse.java b/src/main/java/com/genius/gitget/challenge/certification/dto/github/PullRequestResponse.java new file mode 100644 index 00000000..b7a5decd --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/dto/github/PullRequestResponse.java @@ -0,0 +1,31 @@ +package com.genius.gitget.challenge.certification.dto.github; + +import com.genius.gitget.global.util.exception.BusinessException; +import java.io.IOException; +import java.util.Date; +import lombok.Builder; +import org.kohsuke.github.GHPullRequest; + +@Builder +public record PullRequestResponse( + String prTitle, + String prLink, + String state, + Date createdAt, + Date closedAt +) { + + public static PullRequestResponse create(GHPullRequest ghPullRequest) { + try { + return PullRequestResponse.builder() + .prTitle(ghPullRequest.getTitle()) + .prLink(String.valueOf(ghPullRequest.getHtmlUrl())) + .state(ghPullRequest.getState().name()) + .createdAt(ghPullRequest.getCreatedAt()) + .closedAt(ghPullRequest.getClosedAt()) + .build(); + } catch (IOException e) { + throw new BusinessException(e); + } + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/facade/CertificationFacade.java b/src/main/java/com/genius/gitget/challenge/certification/facade/CertificationFacade.java new file mode 100644 index 00000000..7227afdf --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/facade/CertificationFacade.java @@ -0,0 +1,33 @@ +package com.genius.gitget.challenge.certification.facade; + +import com.genius.gitget.challenge.certification.dto.CertificationInformation; +import com.genius.gitget.challenge.certification.dto.CertificationRequest; +import com.genius.gitget.challenge.certification.dto.CertificationResponse; +import com.genius.gitget.challenge.certification.dto.InstancePreviewResponse; +import com.genius.gitget.challenge.certification.dto.TotalResponse; +import com.genius.gitget.challenge.certification.dto.WeekResponse; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.user.domain.User; +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface CertificationFacade { + WeekResponse getMyWeekCertifications(Long participantId, LocalDate currentDate); + + Slice getOthersWeekCertifications(Long userId, Long instanceId, + LocalDate currentDate, Pageable pageable); + + TotalResponse getTotalCertification(Long participantId, LocalDate currentDate); + + ActivatedResponse passCertification(Long userId, CertificationRequest certificationRequest); + + CertificationResponse updateCertification(User user, CertificationRequest certificationRequest); + + CertificationInformation getCertificationInformation(Instance instance, Participant participant, + LocalDate currentDate); + + InstancePreviewResponse getInstancePreview(Long instanceId); +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/facade/GithubFacade.java b/src/main/java/com/genius/gitget/challenge/certification/facade/GithubFacade.java new file mode 100644 index 00000000..dfe15b7b --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/facade/GithubFacade.java @@ -0,0 +1,21 @@ +package com.genius.gitget.challenge.certification.facade; + +import com.genius.gitget.challenge.certification.dto.github.PullRequestResponse; +import com.genius.gitget.challenge.user.domain.User; +import java.time.LocalDate; +import java.util.List; + +public interface GithubFacade { + void registerGithubPersonalToken(User user, String githubToken); + + void verifyGithubToken(User user); + + void verifyRepository(User user, String repository); + + List getPublicRepositories(User user); + + List verifyPullRequest(User user, String repositoryName, LocalDate targetDate); + + List getPullRequestListByDate(User user, String repositoryName, + LocalDate targetDate); +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/repository/CertificationRepository.java b/src/main/java/com/genius/gitget/challenge/certification/repository/CertificationRepository.java new file mode 100644 index 00000000..d27abbed --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/repository/CertificationRepository.java @@ -0,0 +1,29 @@ +package com.genius.gitget.challenge.certification.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; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; + +public interface CertificationRepository extends JpaRepository { + + @Query("select c from Certification c where c.participant.id = :participantId AND c.certificatedAt = :targetDate") + Optional findByDate(@Param("targetDate") LocalDate targetDate, + @Param("participantId") Long participantId); + + @Query("select c from Certification c where c.participant.id = :participantId and c.certificatedAt between :startDate AND :endDate order by c.currentAttempt desc") + List findByDuration(@Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("participantId") Long participantId); + + @Query("select c from Certification c where c.participant.id = :participantId and c.certificatedAt <= :currentDate AND c.certificationStatus = :status") + List findByStatus(@Param("participantId") Long participantId, + @Param("status") CertificateStatus status, + @Param("currentDate") LocalDate currentDate); +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/service/CertificationFacadeService.java b/src/main/java/com/genius/gitget/challenge/certification/service/CertificationFacadeService.java new file mode 100644 index 00000000..dda44f25 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/service/CertificationFacadeService.java @@ -0,0 +1,274 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.CERTIFICATED; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.PASSED; + +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.dto.CertificationInformation; +import com.genius.gitget.challenge.certification.dto.CertificationRequest; +import com.genius.gitget.challenge.certification.dto.CertificationResponse; +import com.genius.gitget.challenge.certification.dto.InstancePreviewResponse; +import com.genius.gitget.challenge.certification.dto.TotalResponse; +import com.genius.gitget.challenge.certification.dto.WeekResponse; +import com.genius.gitget.challenge.certification.facade.CertificationFacade; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.service.InstanceService; +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.service.ParticipantService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.dto.UserProfileInfo; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.store.item.service.OrdersService; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GitHub; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CertificationFacadeService implements CertificationFacade { + private final FilesManager filesManager; + + private final UserService userService; + private final InstanceService instanceService; + private final ParticipantService participantService; + private final GithubService githubService; + private final CertificationService certificationService; + private final OrdersService ordersService; + + @Override + public WeekResponse getMyWeekCertifications(Long participantId, LocalDate currentDate) { + Participant participant = participantService.findById(participantId); + return getWeekResponse(participant, currentDate); + } + + @Override + public Slice getOthersWeekCertifications(Long userId, Long instanceId, + LocalDate currentDate, Pageable pageable) { + Slice participants = participantService.findAllByInstanceId(userId, instanceId, pageable); + return participants.map( + participant -> getWeekResponse(participant, currentDate) + ); + } + + private WeekResponse getWeekResponse(Participant participant, LocalDate currentDate) { + Instance instance = participant.getInstance(); + LocalDate instanceStartDate = instance.getStartedDate().toLocalDate(); + LocalDate weekStartDate = DateUtil.getWeekStartDate(instanceStartDate, currentDate); + + UserProfileInfo userProfileInfo = getUserProfileInfo(participant.getUser()); + + if (!instance.isActivatedInstance()) { + return WeekResponse.create(userProfileInfo, new ArrayList<>()); + } + + List certifications = certificationService.findByDuration( + weekStartDate, currentDate, participant.getId()); + + List weekCertifications = getWeekCertifications( + certifications, instanceStartDate, currentDate); + + return WeekResponse.create(userProfileInfo, weekCertifications); + } + + private UserProfileInfo getUserProfileInfo(User user) { + Long frameId = ordersService.getUsingFrameItem(user.getId()).getId(); + FileResponse fileResponse = filesManager.convertToFileResponse(user.getFiles()); + + return UserProfileInfo.createByEntity(user, frameId, fileResponse); + } + + private List getWeekCertifications(List certifications, + LocalDate startDate, LocalDate currentDate) { + List results = new ArrayList<>(); + Map weekMap = new HashMap<>(); + for (Certification certification : certifications) { + weekMap.put(certification.getCertificatedAt(), certification); + } + + LocalDate weekStartDate = DateUtil.getWeekStartDate(startDate, currentDate).minusDays(1); + while (weekStartDate.isBefore(currentDate)) { + weekStartDate = weekStartDate.plusDays(1); + if (weekMap.containsKey(weekStartDate)) { + results.add(CertificationResponse.createExist(weekMap.get(weekStartDate))); + continue; + } + results.add(CertificationResponse.createNonExist(0, weekStartDate)); + } + return results; + } + + @Override + public TotalResponse getTotalCertification(Long participantId, LocalDate currentDate) { + Instance instance = participantService.getInstanceById(participantId); + LocalDate startDate = instance.getStartedDate().toLocalDate(); + + int totalAttempts = instance.getTotalAttempt(); + int curAttempt = DateUtil.getAttemptCount(startDate, currentDate); + + if (instance.getProgress() == Progress.DONE) { + currentDate = instance.getCompletedDate().toLocalDate(); + curAttempt = totalAttempts; + } + + List certifications = certificationService.findByDuration( + startDate, currentDate, participantId); + + List totalCertifications = getTotalCertifications( + certifications, curAttempt, startDate); + + return TotalResponse.builder() + .totalAttempts(totalAttempts) + .certifications(totalCertifications) + .build(); + } + + private List getTotalCertifications(List certifications, + int curAttempt, LocalDate startedDate) { + List result = new ArrayList<>(); + Map totalMap = new HashMap<>(); + + for (Certification certification : certifications) { + totalMap.put(certification.getCurrentAttempt(), certification); + } + + startedDate = startedDate.minusDays(1); + + for (int cur = 1; cur <= curAttempt; cur++) { + startedDate = startedDate.plusDays(1); + if (totalMap.containsKey(cur)) { + result.add(CertificationResponse.createExist(totalMap.get(cur))); + continue; + } + result.add(CertificationResponse.createNonExist(cur, startedDate)); + } + + return result; + } + + @Override + @Transactional + public ActivatedResponse passCertification(Long userId, CertificationRequest certificationRequest) { + Instance instance = instanceService.findInstanceById(certificationRequest.instanceId()); + Participant participant = participantService.findByJoinInfo(userId, instance.getId()); + LocalDate targetDate = certificationRequest.targetDate(); + + Certification certification = certificationService.findOrSave(participant, NOT_YET, targetDate); + + instance.validateCertificateCondition(targetDate); + certification.validatePassCondition(); + + certification.updateToPass(targetDate); + + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + return ActivatedResponse.of(instance, certification.getCertificationStatus(), + 0, participant.getRepositoryName(), fileResponse); + } + + @Override + @Transactional + public CertificationResponse updateCertification(User user, CertificationRequest certificationRequest) { + GitHub gitHub = githubService.getGithubConnection(user); + Instance instance = instanceService.findInstanceById(certificationRequest.instanceId()); + Participant participant = participantService.findByJoinInfo(user.getId(), instance.getId()); + + String repositoryName = participant.getRepositoryName(); + LocalDate targetDate = certificationRequest.targetDate(); + + instance.validateCertificateCondition(targetDate); + + List filteredPullRequests = githubService.filterValidPR( + githubService.getPullRequestByDate(gitHub, repositoryName, targetDate), + instance.getPrTemplate(targetDate) + ); + + certificationService.findOrSave(participant, NOT_YET, targetDate); + + Certification certification = certificationService.findByDate(targetDate, participant.getId()) + .map(updated -> { + updated.validateCertificateCondition(); + return certificationService.update(updated, targetDate, filteredPullRequests); + }) + .orElseGet( + () -> certificationService.createCertificated(participant, targetDate, filteredPullRequests) + ); + + return CertificationResponse.createExist(certification); + } + + @Override + public InstancePreviewResponse getInstancePreview(Long instanceId) { + Instance instance = instanceService.findInstanceById(instanceId); + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + return InstancePreviewResponse.createByEntity(instance, fileResponse); + } + + @Override + @Transactional + public CertificationInformation getCertificationInformation(Instance instance, Participant participant, + LocalDate currentDate) { + int successCount = 0; + int failureCount = 0; + int remainCount = 0; + + int totalAttempt = instance.getTotalAttempt(); + int currentAttempt = 0; + + switch (instance.getProgress()) { + case PREACTIVITY -> { + remainCount = instance.getTotalAttempt(); + } + case ACTIVITY -> { + currentAttempt = DateUtil.getAttemptCount(instance.getStartedDate().toLocalDate(), currentDate); + successCount = calculateSuccess(participant.getId(), currentDate); + failureCount = currentAttempt - successCount; + remainCount = totalAttempt - currentAttempt; + + } + case DONE -> { + currentAttempt = totalAttempt; + successCount = calculateSuccess(participant.getId(), instance.getCompletedDate().toLocalDate()); + failureCount = totalAttempt - successCount; + } + } + + return CertificationInformation.builder() + .prTemplate(instance.getPrTemplate(currentDate)) + .repository(participant.getRepositoryName()) + .successPercent(getSuccessPercent(successCount, currentAttempt)) + .totalAttempt(totalAttempt) + .currentAttempt(currentAttempt) + .pointPerPerson(instance.getPointPerPerson()) + .successCount(successCount) + .failureCount(failureCount) + .remainCount(remainCount) + .build(); + } + + private int calculateSuccess(Long participantId, LocalDate currentDate) { + int certificated = certificationService.countByStatus(participantId, CERTIFICATED, currentDate); + int passed = certificationService.countByStatus(participantId, PASSED, currentDate); + return certificated + passed; + } + + private double getSuccessPercent(int successCount, int currentAttempt) { + double successPercent = (double) successCount / (double) currentAttempt * 100; + return Math.round(successPercent * 100 / 100.0); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/certification/service/CertificationService.java b/src/main/java/com/genius/gitget/challenge/certification/service/CertificationService.java new file mode 100644 index 00000000..b60400a4 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/service/CertificationService.java @@ -0,0 +1,107 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.CERTIFICATED; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.repository.CertificationRepository; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.participant.domain.Participant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CertificationService { + private final CertificationRepository certificationRepository; + + + public List findByDuration(LocalDate startDate, LocalDate endDate, Long participantId) { + return certificationRepository.findByDuration(startDate, endDate, participantId); + } + + public Optional findByDate(LocalDate targetDate, Long participantId) { + return certificationRepository.findByDate(targetDate, participantId); + } + + public int countByStatus(Long participantId, CertificateStatus certificateStatus, + LocalDate targetDate) { + return certificationRepository.findByStatus(participantId, certificateStatus, targetDate).size(); + } + + @Transactional + public Certification save(Certification certification) { + return certificationRepository.save(certification); + } + + @Transactional + public Certification update(Certification certification, + LocalDate targetDate, + List pullRequests) { + certification.update( + targetDate, getCertificateStatus(pullRequests), getPrLinks(pullRequests) + ); + return certification; + } + + @Transactional + public Certification findOrSave(Participant participant, CertificateStatus status, LocalDate targetDate) { + int currentAttempt = DateUtil.getAttemptCount(participant.getStartedDate(), targetDate); + + return findByDate(targetDate, participant.getId()) + .orElseGet(() -> { + Certification certification = Certification.of(status, currentAttempt, targetDate); + certification.setParticipant(participant); + return certificationRepository.save(certification); + }); + } + + @Transactional + public Certification createCertificated(Participant participant, + LocalDate targetDate, + List pullRequests) { + int attempt = DateUtil.getAttemptCount(participant.getStartedDate(), targetDate); + + Certification certification = Certification.builder() + .currentAttempt(attempt) + .certificatedAt(targetDate) + .certificationStatus(getCertificateStatus(pullRequests)) + .certificationLinks(getPrLinks(pullRequests)) + .build(); + + certification.setParticipant(participant); + + return certificationRepository.save(certification); + } + + private String getPrLinks(List pullRequests) { + StringBuilder prLinkBuilder = new StringBuilder(); + for (String pullRequest : pullRequests) { + prLinkBuilder.append(pullRequest); + prLinkBuilder.append(","); + } + return prLinkBuilder.toString(); + } + + private CertificateStatus getCertificateStatus(List pullRequests) { + if (pullRequests.isEmpty()) { + return NOT_YET; + } + return CERTIFICATED; + } + + public double getAchievementRate(Instance instance, Long participantId, LocalDate targetDate) { + int totalAttempt = instance.getTotalAttempt(); + int successCount = countByStatus(participantId, CERTIFICATED, targetDate); + + double successPercent = (double) successCount / (double) totalAttempt * 100; + return Math.round(successPercent * 100 / 100.0); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/service/GithubFacadeService.java b/src/main/java/com/genius/gitget/challenge/certification/service/GithubFacadeService.java new file mode 100644 index 00000000..350f1e15 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/service/GithubFacadeService.java @@ -0,0 +1,87 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_PR_NOT_FOUND; + +import com.genius.gitget.challenge.certification.dto.github.PullRequestResponse; +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.certification.util.EncryptUtil; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.util.exception.BusinessException; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GithubFacadeService implements GithubFacade { + private final UserService userService; + private final GithubService githubService; + private final EncryptUtil encryptUtil; + + @Override + @Transactional + public void registerGithubPersonalToken(User user, String githubToken) { + GitHub gitHub = githubService.getGithubConnection(githubToken); + githubService.validateGithubConnection(gitHub, user.getIdentifier()); + + String encryptedToken = encryptUtil.encrypt(githubToken); + user.updateGithubPersonalToken(encryptedToken); + userService.save(user); + } + + @Override + public void verifyGithubToken(User user) { + String githubToken = encryptUtil.decrypt(user.getGithubToken()); + + GitHub gitHub = githubService.getGithubConnection(githubToken); + githubService.validateGithubConnection(gitHub, user.getIdentifier()); + } + + @Override + @Transactional + public void verifyRepository(User user, String repository) { + GitHub gitHub = githubService.getGithubConnection(user); + + String repositoryFullName = githubService.getRepoFullName(gitHub, repository); + githubService.validateGithubRepository(gitHub, repositoryFullName); + } + + @Override + public List getPublicRepositories(User user) { + GitHub gitHub = githubService.getGithubConnection(user); + List repositoryList = githubService.getRepositoryList(gitHub); + return repositoryList.stream() + .map(GHRepository::getName) + .toList(); + } + + @Override + public List verifyPullRequest(User user, String repositoryName, LocalDate targetDate) { + List responses = getPullRequestListByDate(user, repositoryName, targetDate); + + if (responses.isEmpty()) { + throw new BusinessException(GITHUB_PR_NOT_FOUND); + } + return responses; + } + + @Override + public List getPullRequestListByDate(User user, String repositoryName, LocalDate targetDate) { + GitHub gitHub = githubService.getGithubConnection(user); + + List pullRequest = githubService.getPullRequestByDate(gitHub, repositoryName, targetDate); + + return pullRequest.stream() + .map(PullRequestResponse::create) + .toList(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/service/GithubService.java b/src/main/java/com/genius/gitget/challenge/certification/service/GithubService.java new file mode 100644 index 00000000..0575656b --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/service/GithubService.java @@ -0,0 +1,146 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_CONNECTION_FAILED; +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_ID_INCORRECT; +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_REPOSITORY_INCORRECT; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.util.exception.BusinessException; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.kohsuke.github.GHDirection; +import org.kohsuke.github.GHFileNotFoundException; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHPullRequestSearchBuilder; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHRepositorySearchBuilder; +import org.kohsuke.github.GHRepositorySearchBuilder.Sort; +import org.kohsuke.github.GHUser; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GithubService { + private final UserService userService; + + + public GitHub getGithubConnection(String githubToken) { + try { + GitHub gitHub = new GitHubBuilder().withOAuthToken(githubToken).build(); + gitHub.checkApiUrlValidity(); + return gitHub; + } catch (IOException e) { + throw new BusinessException(GITHUB_CONNECTION_FAILED); + } + } + + public GitHub getGithubConnection(User user) { + try { + String githubToken = userService.getGithubToken(user); + GitHub gitHub = new GitHubBuilder().withOAuthToken(githubToken).build(); + gitHub.checkApiUrlValidity(); + return gitHub; + } catch (IOException e) { + throw new BusinessException(GITHUB_CONNECTION_FAILED); + } + } + + public void validateGithubConnection(GitHub gitHub, String githubId) { + try { + String accountId = gitHub.getMyself().getLogin(); + validateGithubAccount(githubId, accountId); + } catch (IOException e) { + throw new BusinessException(GITHUB_CONNECTION_FAILED); + } + } + + private void validateGithubAccount(String githubId, String accountId) { + if (!githubId.equals(accountId)) { + throw new BusinessException(GITHUB_ID_INCORRECT); + } + } + + public void validateGithubRepository(GitHub gitHub, String repositoryFullName) { + try { + gitHub.getRepository(repositoryFullName); + } catch (GHFileNotFoundException e) { + throw new BusinessException(GITHUB_REPOSITORY_INCORRECT); + } catch (IllegalArgumentException | IOException e) { + throw new BusinessException(e); + } + } + + public List getRepositoryList(GitHub gitHub) { + try { + GHRepositorySearchBuilder builder = gitHub.searchRepositories() + .user(getGHUser(gitHub).getLogin()) + .sort(Sort.UPDATED) + .order(GHDirection.DESC); + return builder.list().iterator().nextPage(); + } catch (IOException e) { + throw new BusinessException(e); + } + } + + public List getPullRequestByDate(GitHub gitHub, String repositoryName, + LocalDate kstDate) { + try { + GHRepository repository = gitHub.getRepository(getRepoFullName(gitHub, repositoryName)); + GHPullRequestSearchBuilder prSearchBuilder = gitHub.searchPullRequests() + .repo(repository) + .author(getGHUser(gitHub)) + .created(kstDate.minusDays(1), kstDate); + + return prSearchBuilder.list().iterator().nextPage().stream() + .filter(pr -> isEqualToKST(pr, kstDate)) + .toList(); + + } catch (GHFileNotFoundException e) { + throw new BusinessException(GITHUB_REPOSITORY_INCORRECT); + } catch (IOException e) { + throw new BusinessException(e); + } + } + + private boolean isEqualToKST(GHPullRequest ghPullRequest, LocalDate targetDate) { + try { + LocalDate kst = DateUtil.convertToKST(ghPullRequest.getCreatedAt()); + return kst.isEqual(targetDate); + } catch (IOException e) { + throw new BusinessException(e); + } + } + + public List filterValidPR(List ghPullRequests, String prTemplate) { + return ghPullRequests.stream() + .filter(ghPullRequest -> { + if (ghPullRequest.getBody() == null) { + return false; + } + return ghPullRequest.getBody().contains(prTemplate); + }) + .map(ghPullRequest -> ghPullRequest.getHtmlUrl().toString()) + .toList(); + } + + private GHUser getGHUser(GitHub gitHub) throws IOException { + String accountId = gitHub.getMyself().getLogin(); + return gitHub.getUser(accountId); + } + + public String getRepoFullName(GitHub gitHub, String repositoryName) { + try { + return gitHub.getMyself().getLogin() + "/" + repositoryName; + } catch (IOException e) { + throw new BusinessException(e); + } + } +} diff --git a/src/main/java/com/genius/gitget/challenge/certification/util/DateUtil.java b/src/main/java/com/genius/gitget/challenge/certification/util/DateUtil.java new file mode 100644 index 00000000..c36950c9 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/util/DateUtil.java @@ -0,0 +1,91 @@ +package com.genius.gitget.challenge.certification.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public final class DateUtil { + + public static int getRemainDaysToStart(LocalDate startDate, LocalDate targetDate) { + if (targetDate.isBefore(startDate)) { + return (int) ChronoUnit.DAYS.between(targetDate, startDate); + } + return 0; + } + + public static int getAttemptCount(LocalDate startDate, LocalDate targetDate) { + return Math.toIntExact(ChronoUnit.DAYS.between(startDate, targetDate)) + 1; + } + + public static int getWeekAttempt(LocalDate challengeStartDate, LocalDate targetDate) { + int weekAttempt = targetDate.getDayOfWeek().ordinal() + 1; + int totalAttempt = getAttemptCount(challengeStartDate, targetDate); + + if (isFirstWeek(challengeStartDate, targetDate)) { + return totalAttempt; + } + + return weekAttempt; + } + + public static LocalDate getWeekStartDate(LocalDate challengeStartDate, LocalDate currentDate) { + if (isFirstWeek(challengeStartDate, currentDate)) { + return challengeStartDate; + } + LocalDate mondayOfWeek = currentDate.minusDays(currentDate.getDayOfWeek().ordinal()); + return mondayOfWeek; + } + + public static LocalDate convertToKST(Date date) { + return LocalDate.ofInstant( + date.toInstant(), + ZoneId.of("Asia/Seoul") + ); + } + + public static LocalDateTime getKstLocalTime() { + ZoneId systemZone = ZoneId.systemDefault(); + ZoneId koreaZone = ZoneId.of("Asia/Seoul"); + + // LocalDate.now()를 호출하여 LocalDateTime을 생성 + LocalDateTime nowLocal = LocalDateTime.now(); + // 현재 시스템의 ZoneId를 이용하여 ZonedDateTime을 생성 + ZonedDateTime nowZone = ZonedDateTime.of(nowLocal, systemZone); + + // KST(Asia/Seoul)로 변환 + ZonedDateTime koreaTime = nowZone.withZoneSameInstant(koreaZone); + + // LocalDateTime으로 변환하여 반환 + return koreaTime.toLocalDateTime(); + } + + public static LocalDate convertToKST(LocalDateTime nowLocal) { + ZoneId systemZone = ZoneId.systemDefault(); + ZoneId koreaZone = ZoneId.of("Asia/Seoul"); + + // 현재 시스템의 ZoneId를 이용하여 ZonedDateTime을 생성 + ZonedDateTime nowZone = ZonedDateTime.of(nowLocal, systemZone); + + // KST(Asia/Seoul)로 변환 + ZonedDateTime koreaTime = nowZone.withZoneSameInstant(koreaZone); + + return koreaTime.toLocalDate(); + } + + private static boolean isFirstWeek(LocalDate challengeStartDate, LocalDate currentDate) { + LocalDate mondayOfWeek = challengeStartDate.minusDays(challengeStartDate.getDayOfWeek().ordinal()); + LocalDate sundayOfWeek = mondayOfWeek.plusDays(6); + + if (currentDate.isAfter(mondayOfWeek.minusDays(1)) + && currentDate.isBefore(sundayOfWeek.plusDays(1))) { + return true; + } + return false; + } +} + diff --git a/src/main/java/com/genius/gitget/challenge/certification/util/EncryptUtil.java b/src/main/java/com/genius/gitget/challenge/certification/util/EncryptUtil.java new file mode 100644 index 00000000..e5c1bb4c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/certification/util/EncryptUtil.java @@ -0,0 +1,42 @@ +package com.genius.gitget.challenge.certification.util; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.encrypt.AesBytesEncryptor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public final class EncryptUtil { + private final AesBytesEncryptor encryptor; + + public String encrypt(String target) { + byte[] encrypt = encryptor.encrypt(target.getBytes(StandardCharsets.UTF_8)); + return byteArrayToString(encrypt); + } + + public String decrypt(String encrypted) { + byte[] decryptBytes = stringToByteArray(encrypted); + byte[] decrypt = encryptor.decrypt(decryptBytes); + return new String(decrypt, StandardCharsets.UTF_8); + } + + private String byteArrayToString(byte[] bytes) { + StringBuilder builder = new StringBuilder(); + for (byte aByte : bytes) { + builder.append(aByte); + builder.append(" "); + } + return builder.toString(); + } + + private byte[] stringToByteArray(String byteString) { + String[] split = byteString.split("\\s"); + ByteBuffer buffer = ByteBuffer.allocate(split.length); + for (String single : split) { + buffer.put((byte) Integer.parseInt(single)); + } + return buffer.array(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceController.java b/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceController.java new file mode 100644 index 00000000..9cb794a4 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceController.java @@ -0,0 +1,109 @@ +package com.genius.gitget.challenge.instance.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.CREATED; +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.dto.crud.InstanceCreateRequest; +import com.genius.gitget.challenge.instance.dto.crud.InstanceDetailResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstanceIndexResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstancePagingResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstanceUpdateRequest; +import com.genius.gitget.challenge.instance.facade.InstanceFacade; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.PagingResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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.DeleteMapping; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class InstanceController { + private final InstanceFacade instanceFacade; + + // 인스턴스 생성 + @PostMapping("/instance") + public ResponseEntity> createInstance( + @RequestBody InstanceCreateRequest instanceCreateRequest) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + Long instanceId = instanceFacade.createInstance(instanceCreateRequest, kstDate); + InstanceIndexResponse instanceIndexResponse = new InstanceIndexResponse(instanceId); + + return ResponseEntity.ok().body( + new SingleResponse<>(CREATED.getStatus(), CREATED.getMessage(), instanceIndexResponse) + ); + } + + // 인스턴스 수정 + @PatchMapping("/instance/{id}") + public ResponseEntity> updateInstance( + @PathVariable Long id, + @RequestBody InstanceUpdateRequest instanceUpdateRequest) { + + Long instanceId = instanceFacade.modifyInstance(id, instanceUpdateRequest); + InstanceIndexResponse instanceIndexResponse = new InstanceIndexResponse(instanceId); + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), instanceIndexResponse) + ); + } + + // 인스턴스 삭제 + @DeleteMapping("/instance/{id}") + public ResponseEntity deleteInstance(@PathVariable Long id) { + instanceFacade.removeInstance(id); + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } + + // 인스턴스 단건 조회 + @GetMapping("/instance/{id}") + public ResponseEntity> getInstanceById(@PathVariable Long id) { + InstanceDetailResponse instanceDetails = instanceFacade.findOne(id); + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), instanceDetails) + ); + } + + // 인스턴스 리스트 조회 + @GetMapping("/instance") + public ResponseEntity> getAllInstances( + @PageableDefault(size = 5, direction = Sort.Direction.ASC, sort = "id") Pageable pageable) { + Page instances = instanceFacade.findAllInstances(pageable); + + return ResponseEntity.ok().body( + new PagingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), instances) + ); + } + + // 특정 토픽에 대한 리스트 조회 + @GetMapping("topic/instances/{id}") + public ResponseEntity> getAllInstancesOfSpecificTopic( + @PageableDefault Pageable pageable, @PathVariable Long id) { + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by("id")); + Page allInstancesOfSpecificTopic = instanceFacade.getAllInstancesOfSpecificTopic( + pageRequest, id); + + return ResponseEntity.ok().body( + new PagingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), + allInstancesOfSpecificTopic) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceDetailController.java b/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceDetailController.java new file mode 100644 index 00000000..b12926b1 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceDetailController.java @@ -0,0 +1,79 @@ +package com.genius.gitget.challenge.instance.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.JOIN_SUCCESS; +import static com.genius.gitget.global.util.exception.SuccessCode.QUIT_SUCCESS; +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.dto.detail.InstanceResponse; +import com.genius.gitget.challenge.instance.dto.detail.JoinRequest; +import com.genius.gitget.challenge.instance.dto.detail.JoinResponse; +import com.genius.gitget.challenge.instance.service.InstanceDetailFacade; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/challenges") +public class InstanceDetailController { + private final InstanceDetailFacade instanceDetailFacade; + + + @GetMapping("/{instanceId}") + public ResponseEntity> getInstanceDetail( + @GitGetUser User user, + @PathVariable Long instanceId + ) { + InstanceResponse instanceDetailInformation = instanceDetailFacade.getInstanceDetailInformation( + user, instanceId); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), instanceDetailInformation) + ); + } + + @PostMapping("/{instanceId}") + public ResponseEntity> joinChallenge( + @GitGetUser User user, + @PathVariable Long instanceId, + @RequestParam String repo + ) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + JoinRequest joinRequest = JoinRequest.builder() + .instanceId(instanceId) + .repository(repo) + .todayDate(kstDate) + .build(); + JoinResponse joinResponse = instanceDetailFacade.joinNewChallenge(user, joinRequest); + + return ResponseEntity.ok().body( + new SingleResponse<>(JOIN_SUCCESS.getStatus(), JOIN_SUCCESS.getMessage(), joinResponse) + ); + } + + @DeleteMapping("/{instanceId}") + public ResponseEntity> quitChallenge( + @GitGetUser User user, + @PathVariable Long instanceId + ) { + JoinResponse joinResponse = instanceDetailFacade.quitChallenge(user, instanceId); + + return ResponseEntity.ok().body( + new SingleResponse<>(QUIT_SUCCESS.getStatus(), QUIT_SUCCESS.getMessage(), joinResponse) + ); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceHomeController.java b/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceHomeController.java new file mode 100644 index 00000000..dd8721e7 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/controller/InstanceHomeController.java @@ -0,0 +1,84 @@ +package com.genius.gitget.challenge.instance.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.instance.dto.home.HomeInstanceResponse; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchRequest; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchResponse; +import com.genius.gitget.challenge.instance.facade.InstanceHomeFacade; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.exception.SuccessCode; +import com.genius.gitget.global.util.response.dto.PagingResponse; +import com.genius.gitget.global.util.response.dto.SlicingResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/challenges") +public class InstanceHomeController { + private final InstanceHomeFacade instanceHomeFacade; + + @PostMapping("/search") + public ResponseEntity> searchInstances( + @RequestBody InstanceSearchRequest instanceSearchRequest, Pageable pageable) { + + Page searchResults + = instanceHomeFacade.searchInstancesByKeywordAndProgress(instanceSearchRequest, pageable); + + return ResponseEntity.ok().body( + new PagingResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), searchResults) + ); + } + + @GetMapping("/recommend") + public ResponseEntity> getRecommendInstances( + Pageable pageable, + @GitGetUser User user) { + + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Direction.DESC, "participantCount")); + + Slice recommendations = instanceHomeFacade.recommendInstances( + user, pageRequest); + return ResponseEntity.ok().body( + new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations) + ); + } + + @GetMapping("/popular") + public ResponseEntity> getPopularInstances(Pageable pageable) { + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Direction.DESC, "participantCount")); + + Slice recommendations = instanceHomeFacade.findInstancesByCondition( + pageRequest); + return ResponseEntity.ok().body( + new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations) + ); + } + + @GetMapping("/latest") + public ResponseEntity> getLatestInstances(Pageable pageable) { + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Direction.DESC, "startedDate")); + + Slice recommendations = instanceHomeFacade.findInstancesByCondition( + pageRequest); + return ResponseEntity.ok().body( + new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/instance/domain/Instance.java b/src/main/java/com/genius/gitget/challenge/instance/domain/Instance.java new file mode 100644 index 00000000..b3647531 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/domain/Instance.java @@ -0,0 +1,200 @@ +package com.genius.gitget.challenge.instance.domain; + + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.global.file.domain.FileHolder; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.topic.domain.Topic; +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 jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicInsert; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@Table(name = "instance") +public class Instance implements FileHolder { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "instance_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "files_id") + private Files files; + + @OneToMany(mappedBy = "instance") + private List likesList = new ArrayList<>(); + + @OneToMany(mappedBy = "instance") + private List participantList = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "topic_id") + private Topic topic; + + private String title; + + private String description; + + private String tags; + + private int pointPerPerson; + + private int participantCount; + + private String notice; + + private String certificationMethod; + + @NotNull + @Enumerated(EnumType.STRING) + private Progress progress; + + @Column(name = "started_at") + private LocalDateTime startedDate; + + @Column(name = "completed_at") + private LocalDateTime completedDate; + + @Column(name = "instance_uuid") + private String instanceUUID; + + @Builder + public Instance(String title, String description, String tags, int pointPerPerson, Progress progress, String notice, + String certificationMethod, + LocalDateTime startedDate, LocalDateTime completedDate) { + this.title = title; + this.description = description; + this.tags = tags; + this.pointPerPerson = pointPerPerson; + this.notice = notice; + this.certificationMethod = certificationMethod; + this.progress = progress; + this.startedDate = startedDate; + this.completedDate = completedDate; + } + + + //== 연관관계 편의 메서드 ==// + public void setTopic(Topic topic) { + this.topic = topic; + if (!topic.getInstanceList().contains(this)) { + topic.getInstanceList().add(this); + } + } + + /* + * 인스턴스 수정 + * */ + public void updateInstance(String description, String notice, int pointPerPerson, LocalDateTime startedDate, + LocalDateTime completedDate, String certificationMethod) { + this.description = description; + this.notice = notice; + this.pointPerPerson = pointPerPerson; + this.startedDate = startedDate; + this.completedDate = completedDate; + this.certificationMethod = certificationMethod; + } + + //== 비지니스 로직 ==// + + /* + * 참가자 수 정보 수정 + * */ + public void updateParticipantCount(int amount) { + if (amount < 0 && this.participantCount + amount < 0) { + return; + } + this.participantCount += amount; + } + + /* + * 진행 상황 수정 + * */ + public void updateProgress(Progress progress) { + this.progress = progress; + } + + public int getLikesCount() { + return this.likesList.size(); + } + + @Override + public Optional getFiles() { + return Optional.ofNullable(this.files); + } + + @Override + public void setFiles(Files files) { + this.files = files; + } + + /* + * 챌린지 전체 인증 일자 조회 + * */ + public int getTotalAttempt() { + return DateUtil.getAttemptCount(startedDate.toLocalDate(), completedDate.toLocalDate()); + } + + public boolean isActivatedInstance() { + return this.progress == Progress.ACTIVITY; + } + + /* + * 인스턴스 고유 uuid 설정 + * */ + public void setInstanceUUID(String instanceUUID) { + if (this.instanceUUID != null) { + throw new BusinessException(ErrorCode.UUID_ALREADY_EXISTS); + } else { + this.instanceUUID = instanceUUID; + } + } + + public String getPrTemplate(LocalDate currentDate) { + String today = currentDate.toString().replace("-", ""); + return "GITGET-" + instanceUUID + "-" + today; + } + + public void validateCertificateCondition(LocalDate targetDate) { + if (this.getProgress() != Progress.ACTIVITY) { + throw new BusinessException(ErrorCode.NOT_ACTIVITY_INSTANCE); + } + + LocalDate startedDate = this.getStartedDate().toLocalDate().minusDays(1); + LocalDate completedDate = this.getCompletedDate().toLocalDate().plusDays(1); + + boolean isValidPeriod = targetDate.isAfter(startedDate) && targetDate.isBefore(completedDate); + if (!isValidPeriod) { + throw new BusinessException(ErrorCode.NOT_CERTIFICATE_PERIOD); + } + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/domain/Progress.java b/src/main/java/com/genius/gitget/challenge/instance/domain/Progress.java new file mode 100644 index 00000000..e44ee421 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/domain/Progress.java @@ -0,0 +1,7 @@ +package com.genius.gitget.challenge.instance.domain; + +public enum Progress { + PREACTIVITY, + ACTIVITY, + DONE; +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceCreateRequest.java b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceCreateRequest.java new file mode 100644 index 00000000..54d7e9d9 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceCreateRequest.java @@ -0,0 +1,32 @@ +package com.genius.gitget.challenge.instance.dto.crud; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record InstanceCreateRequest( + Long topicId, + String title, + String tags, + String description, + String notice, + int pointPerPerson, + LocalDateTime startedAt, + LocalDateTime completedAt, + String certificationMethod) { + public static Instance from(InstanceCreateRequest instanceCreateRequest) { + return Instance.builder() + .title(instanceCreateRequest.title()) + .tags(instanceCreateRequest.tags()) + .description(instanceCreateRequest.description()) + .pointPerPerson(instanceCreateRequest.pointPerPerson()) + .notice(instanceCreateRequest.notice()) + .startedDate(instanceCreateRequest.startedAt()) + .completedDate(instanceCreateRequest.completedAt()) + .certificationMethod(instanceCreateRequest.certificationMethod()) + .progress(Progress.PREACTIVITY) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceDetailResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceDetailResponse.java new file mode 100644 index 00000000..748793c5 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceDetailResponse.java @@ -0,0 +1,34 @@ +package com.genius.gitget.challenge.instance.dto.crud; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.dto.FileResponse; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record InstanceDetailResponse( + Long topicId, Long instanceId, + String title, String description, + int pointPerPerson, + String tags, + String notice, + LocalDateTime startedAt, + LocalDateTime completedAt, + String certificationMethod, + FileResponse fileResponse) { + public static InstanceDetailResponse of(Instance instance, FileResponse fileResponse) { + return InstanceDetailResponse.builder() + .topicId(instance.getTopic().getId()) + .instanceId(instance.getId()) + .title(instance.getTitle()) + .description(instance.getDescription()) + .pointPerPerson(instance.getPointPerPerson()) + .tags(instance.getTags()) + .notice(instance.getNotice()) + .startedAt(instance.getStartedDate()) + .completedAt(instance.getCompletedDate()) + .certificationMethod(instance.getCertificationMethod()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceIndexResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceIndexResponse.java new file mode 100644 index 00000000..0deaa84c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceIndexResponse.java @@ -0,0 +1,6 @@ +package com.genius.gitget.challenge.instance.dto.crud; + +public record InstanceIndexResponse( + Long instanceId +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstancePagingResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstancePagingResponse.java new file mode 100644 index 00000000..cfa999d5 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstancePagingResponse.java @@ -0,0 +1,26 @@ +package com.genius.gitget.challenge.instance.dto.crud; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.dto.FileResponse; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record InstancePagingResponse( + Long topicId, + Long instanceId, + String title, + LocalDateTime startedAt, + LocalDateTime completedAt, + FileResponse fileResponse) { + public static InstancePagingResponse of(Instance instance, FileResponse fileResponse) { + return InstancePagingResponse.builder() + .topicId(instance.getTopic().getId()) + .instanceId(instance.getId()) + .title(instance.getTitle()) + .startedAt(instance.getStartedDate()) + .completedAt(instance.getCompletedDate()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceUpdateDTO.java b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceUpdateDTO.java new file mode 100644 index 00000000..746d6ae6 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceUpdateDTO.java @@ -0,0 +1,26 @@ +package com.genius.gitget.challenge.instance.dto.crud; + +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record InstanceUpdateDTO( + String description, + String notice, + int pointPerPerson, + LocalDateTime startedDate, + + LocalDateTime completedDate, + String certificationMethod) { + public static InstanceUpdateDTO of(String description, String notice, int pointPerPerson, LocalDateTime startedDate, + LocalDateTime completedDate, String certificationMethod) { + return InstanceUpdateDTO.builder() + .description(description) + .notice(notice) + .pointPerPerson(pointPerPerson) + .startedDate(startedDate) + .completedDate(completedDate) + .certificationMethod(certificationMethod) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceUpdateRequest.java b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceUpdateRequest.java new file mode 100644 index 00000000..9534007d --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/crud/InstanceUpdateRequest.java @@ -0,0 +1,16 @@ +package com.genius.gitget.challenge.instance.dto.crud; + +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record InstanceUpdateRequest( + Long topicId, + String description, + String notice, + int pointPerPerson, + LocalDateTime startedAt, + LocalDateTime completedAt, + String certificationMethod +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/detail/InstanceResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/InstanceResponse.java new file mode 100644 index 00000000..76a96695 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/InstanceResponse.java @@ -0,0 +1,52 @@ +package com.genius.gitget.challenge.instance.dto.detail; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.global.file.dto.FileResponse; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record InstanceResponse( + Long instanceId, + Progress progress, + String title, + int remainDays, + LocalDate startedDate, + LocalDate completedDate, + int participantCount, + int pointPerPerson, + String description, + String notice, + String certificationMethod, + JoinStatus joinStatus, + LikesInfo likesInfo, + FileResponse fileResponse +) { + + public static InstanceResponse createByEntity(Instance instance, LikesInfo likesInfo, + JoinStatus joinStatus, FileResponse fileResponse) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + LocalDate startedLocalDate = instance.getStartedDate().toLocalDate(); + LocalDate completedLocalDate = instance.getCompletedDate().toLocalDate(); + return InstanceResponse.builder() + .instanceId(instance.getId()) + .progress(instance.getProgress()) + .title(instance.getTitle()) + .remainDays(DateUtil.getRemainDaysToStart(startedLocalDate, kstDate)) + .startedDate(startedLocalDate) + .completedDate(completedLocalDate) + .participantCount(instance.getParticipantCount()) + .pointPerPerson(instance.getPointPerPerson()) + .description(instance.getDescription()) + .notice(instance.getNotice()) + .certificationMethod(instance.getCertificationMethod()) + .joinStatus(joinStatus) + .likesInfo(likesInfo) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/detail/JoinRequest.java b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/JoinRequest.java new file mode 100644 index 00000000..b1144d80 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/JoinRequest.java @@ -0,0 +1,12 @@ +package com.genius.gitget.challenge.instance.dto.detail; + +import java.time.LocalDate; +import lombok.Builder; + +@Builder +public record JoinRequest( + Long instanceId, + String repository, + LocalDate todayDate +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/detail/JoinResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/JoinResponse.java new file mode 100644 index 00000000..5ffaefe2 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/JoinResponse.java @@ -0,0 +1,29 @@ +package com.genius.gitget.challenge.instance.dto.detail; + +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import lombok.Builder; + +@Builder +public record JoinResponse( + Long participantId, + JoinStatus joinStatus, + JoinResult joinResult +) { + public static JoinResponse createJoinResponse(Participant participant) { + return JoinResponse.builder() + .participantId(participant.getId()) + .joinStatus(participant.getJoinStatus()) + .joinResult(participant.getJoinResult()) + .build(); + } + + public static JoinResponse createQuitResponse() { + return JoinResponse.builder() + .participantId(null) + .joinResult(null) + .joinStatus(null) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/detail/LikesInfo.java b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/LikesInfo.java new file mode 100644 index 00000000..489da6b4 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/detail/LikesInfo.java @@ -0,0 +1,27 @@ +package com.genius.gitget.challenge.instance.dto.detail; + +import lombok.Builder; + +@Builder +public record LikesInfo( + Long likesId, + boolean isLiked, + int likesCount +) { + + public static LikesInfo createExist(Long likesId, int likesCount) { + return LikesInfo.builder() + .likesId(likesId) + .isLiked(true) + .likesCount(likesCount) + .build(); + } + + public static LikesInfo createNotExist(int likesCount) { + return LikesInfo.builder() + .likesId(0L) + .isLiked(false) + .likesCount(likesCount) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/home/HomeInstanceResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/home/HomeInstanceResponse.java new file mode 100644 index 00000000..b0235412 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/home/HomeInstanceResponse.java @@ -0,0 +1,24 @@ +package com.genius.gitget.challenge.instance.dto.home; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.dto.FileResponse; +import lombok.Builder; + +@Builder +public record HomeInstanceResponse( + Long instanceId, + String title, + int participantCnt, + int pointPerPerson, + FileResponse fileResponse +) { + public static HomeInstanceResponse createByEntity(Instance instance, FileResponse fileResponse) { + return HomeInstanceResponse.builder() + .instanceId(instance.getId()) + .title(instance.getTitle()) + .participantCnt(instance.getParticipantCount()) + .pointPerPerson(instance.getPointPerPerson()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/search/InstanceSearchRequest.java b/src/main/java/com/genius/gitget/challenge/instance/dto/search/InstanceSearchRequest.java new file mode 100644 index 00000000..6e9b6ad0 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/search/InstanceSearchRequest.java @@ -0,0 +1,9 @@ +package com.genius.gitget.challenge.instance.dto.search; + +import lombok.Builder; + +@Builder +public record InstanceSearchRequest( + String keyword, + String progress) { +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/instance/dto/search/InstanceSearchResponse.java b/src/main/java/com/genius/gitget/challenge/instance/dto/search/InstanceSearchResponse.java new file mode 100644 index 00000000..129ecbbe --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/dto/search/InstanceSearchResponse.java @@ -0,0 +1,30 @@ +package com.genius.gitget.challenge.instance.dto.search; + +import com.genius.gitget.global.file.dto.FileResponse; +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +public class InstanceSearchResponse { + private Long topicId; + private Long instanceId; + private String keyword; + private int pointPerPerson; + private int participantCount; + private FileResponse fileResponse; + + @Builder + @QueryProjection + public InstanceSearchResponse(Long topicId, Long instanceId, String keyword, int pointPerPerson, + int participantCount, FileResponse fileResponse) { + this.topicId = topicId; + this.instanceId = instanceId; + this.keyword = keyword; + this.pointPerPerson = pointPerPerson; + this.participantCount = participantCount; + this.fileResponse = fileResponse; + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceFacade.java b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceFacade.java new file mode 100644 index 00000000..c5893d18 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceFacade.java @@ -0,0 +1,25 @@ +package com.genius.gitget.challenge.instance.facade; + +import com.genius.gitget.challenge.instance.dto.crud.InstanceCreateRequest; +import com.genius.gitget.challenge.instance.dto.crud.InstanceDetailResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstancePagingResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstanceUpdateRequest; +import java.time.LocalDate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface InstanceFacade { + + Long createInstance(InstanceCreateRequest instanceCreateRequest, LocalDate currentDate); + + Page findAllInstances(Pageable pageable); + + Page getAllInstancesOfSpecificTopic(Pageable pageable, Long id); + + InstanceDetailResponse findOne(Long id); + + void removeInstance(Long id); + + Long modifyInstance(Long id, InstanceUpdateRequest instanceUpdateRequest); + +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceFacadeService.java b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceFacadeService.java new file mode 100644 index 00000000..ad3e907c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceFacadeService.java @@ -0,0 +1,81 @@ +package com.genius.gitget.challenge.instance.facade; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.crud.InstanceCreateRequest; +import com.genius.gitget.challenge.instance.dto.crud.InstanceDetailResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstancePagingResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstanceUpdateDTO; +import com.genius.gitget.challenge.instance.dto.crud.InstanceUpdateRequest; +import com.genius.gitget.challenge.instance.service.InstanceService; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +@Slf4j +@Transactional +public class InstanceFacadeService implements InstanceFacade { + private final FilesManager filesManager; + private final InstanceService instanceService; + + // 인스턴스 생성 + @Override + public Long createInstance(InstanceCreateRequest instanceCreateRequest, LocalDate currentDate) { + Instance instance = InstanceCreateRequest.from(instanceCreateRequest); + LocalDate startDate = instanceCreateRequest.startedAt().toLocalDate(); + LocalDate completeDate = instanceCreateRequest.completedAt().toLocalDate(); + + return instanceService.createInstance(instance, instanceCreateRequest.topicId(), startDate, completeDate, + currentDate); + } + + // 인스턴스 수정 + @Override + public Long modifyInstance(Long id, InstanceUpdateRequest instanceUpdateRequest) { + InstanceUpdateDTO updateDTO = InstanceUpdateDTO.of(instanceUpdateRequest.description(), + instanceUpdateRequest.notice(), instanceUpdateRequest.pointPerPerson(), + instanceUpdateRequest.startedAt(), + instanceUpdateRequest.completedAt(), instanceUpdateRequest.certificationMethod()); + + return instanceService.modifyInstance(id, updateDTO); + } + + @Override + public void removeInstance(Long id) { + instanceService.removeInstance(id); + } + + // 인스턴스 단건 조회 + @Override + public InstanceDetailResponse findOne(Long id) { + Instance instance = instanceService.findInstanceById(id); + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + + return InstanceDetailResponse.of(instance, fileResponse); + } + + // 인스턴스 전체 조회 + @Override + public Page findAllInstances(Pageable pageable) { + Page allInstances = instanceService.findAllInstances(pageable); + return allInstances.map(this::mapToInstancePagingResponse); + } + + @Override + public Page getAllInstancesOfSpecificTopic(Pageable pageable, Long id) { + Page allInstancesOfSpecificTopic = instanceService.getAllInstancesOfSpecificTopic(pageable, id); + return allInstancesOfSpecificTopic.map(this::mapToInstancePagingResponse); + } + + private InstancePagingResponse mapToInstancePagingResponse(Instance instance) { + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + return InstancePagingResponse.of(instance, fileResponse); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceHomeFacade.java b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceHomeFacade.java new file mode 100644 index 00000000..17496c4b --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceHomeFacade.java @@ -0,0 +1,19 @@ +package com.genius.gitget.challenge.instance.facade; + +import com.genius.gitget.challenge.instance.dto.home.HomeInstanceResponse; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchRequest; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchResponse; +import com.genius.gitget.challenge.user.domain.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface InstanceHomeFacade { + + Page searchInstancesByKeywordAndProgress(InstanceSearchRequest instanceSearchRequest, + Pageable pageable); + + Slice recommendInstances(User user, Pageable pageable); + + Slice findInstancesByCondition(Pageable pageable); +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceHomeFacadeService.java b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceHomeFacadeService.java new file mode 100644 index 00000000..f614ac9c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/facade/InstanceHomeFacadeService.java @@ -0,0 +1,84 @@ +package com.genius.gitget.challenge.instance.facade; + + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.home.HomeInstanceResponse; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchRequest; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchResponse; +import com.genius.gitget.challenge.instance.service.InstanceRecommendationService; +import com.genius.gitget.challenge.instance.service.InstanceSearchService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +@Slf4j +@Transactional +public class InstanceHomeFacadeService implements InstanceHomeFacade { + + private final InstanceRecommendationService instanceRecommendationService; + private final InstanceSearchService instanceSearchService; + private final FilesManager filesManager; + + @Override + public Page searchInstancesByKeywordAndProgress(InstanceSearchRequest instanceSearchRequest, + Pageable pageable) { + Page searchedInstances = instanceSearchService.searchInstances(instanceSearchRequest.keyword(), + instanceSearchRequest.progress(), pageable); + + return searchedInstances.map(this::convertToSearchResponse); + } + + @Override + public Slice recommendInstances(User user, Pageable pageable) { + List instanceList = instanceRecommendationService.getRecommendations(user); + List recommendations = convertToHomeInstanceResponseList(instanceList); + return createPageFromList(recommendations, pageable); + } + + @Override + public Slice findInstancesByCondition(Pageable pageable) { + Slice instancesByCondition = instanceRecommendationService.getInstancesByCondition(pageable); + return instancesByCondition.map(this::mapToHomeInstanceResponse); + } + + private InstanceSearchResponse convertToSearchResponse(Instance instance) { + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + return InstanceSearchResponse.builder() + .topicId(instance.getTopic().getId()) + .instanceId(instance.getId()) + .keyword(instance.getTitle()) + .pointPerPerson(instance.getPointPerPerson()) + .participantCount(instance.getParticipantCount()) + .fileResponse(fileResponse) + .build(); + } + + private List convertToHomeInstanceResponseList(List instances) { + return instances.stream() + .map(this::mapToHomeInstanceResponse) + .collect(Collectors.toList()); + } + + private HomeInstanceResponse mapToHomeInstanceResponse(Instance instance) { + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + return HomeInstanceResponse.createByEntity(instance, fileResponse); + } + + private Slice createPageFromList(List list, Pageable pageable) { + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), list.size()); + return new PageImpl<>(list.subList(start, end), pageable, list.size()); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/repository/InstanceRepository.java b/src/main/java/com/genius/gitget/challenge/instance/repository/InstanceRepository.java new file mode 100644 index 00000000..de98aaa6 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/repository/InstanceRepository.java @@ -0,0 +1,29 @@ +package com.genius.gitget.challenge.instance.repository; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import java.util.List; +import org.springframework.data.domain.Page; +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 InstanceRepository extends JpaRepository { + + @Query("select i from Instance i ORDER BY i.id DESC ") + Page findAllById(Pageable pageable); + + @Query("select i from Instance i where i.topic.id = :topicId") + Page findInstancesByTopicId(Pageable pageable, Long topicId); + + @Query("select i from Instance i where i.progress = :progress and i.tags like %:userTag%") + List findRecommendations(@Param("userTag") String userTag, Progress progress); + + @Query("select i from Instance i where i.progress = :progress") + Slice findPagesByProgress(@Param("progress") Progress progress, Pageable pageable); + + @Query("select i from Instance i where i.progress = :progress") + List findAllByProgress(@Param("progress") Progress progress); +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepository.java b/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepository.java new file mode 100644 index 00000000..6a684ae1 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepository.java @@ -0,0 +1,7 @@ +package com.genius.gitget.challenge.instance.repository; + +import com.genius.gitget.challenge.instance.domain.Instance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SearchRepository extends JpaRepository, SearchRepositoryCustom { +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepositoryCustom.java b/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepositoryCustom.java new file mode 100644 index 00000000..95b22c7a --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.genius.gitget.challenge.instance.repository; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SearchRepositoryCustom { + Page search(Progress progress, String title, Pageable pageable); +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepositoryImpl.java b/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepositoryImpl.java new file mode 100644 index 00000000..90b791c7 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/repository/SearchRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.genius.gitget.challenge.instance.repository; + +import static com.genius.gitget.challenge.instance.domain.QInstance.instance; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +public class SearchRepositoryImpl implements SearchRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + public SearchRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public Page search(Progress progressCond, String titleCond, Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder(); + + if (progressCond != null) { + builder.and(instance.progress.eq(progressCond)); + } + if (titleCond != null) { + builder.and(instance.title.contains(titleCond)); + } + + List content = queryFactory + .selectFrom(instance) + .where(builder) + .orderBy(instance.startedDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(instance.count()) + .from(instance) + .where(builder); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/instance/service/InstanceDetailFacade.java b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceDetailFacade.java new file mode 100644 index 00000000..f500cdd4 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceDetailFacade.java @@ -0,0 +1,14 @@ +package com.genius.gitget.challenge.instance.service; + +import com.genius.gitget.challenge.instance.dto.detail.InstanceResponse; +import com.genius.gitget.challenge.instance.dto.detail.JoinRequest; +import com.genius.gitget.challenge.instance.dto.detail.JoinResponse; +import com.genius.gitget.challenge.user.domain.User; + +public interface InstanceDetailFacade { + InstanceResponse getInstanceDetailInformation(User user, Long instanceId); + + JoinResponse joinNewChallenge(User user, JoinRequest joinRequest); + + public JoinResponse quitChallenge(User user, Long instanceId); +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/service/InstanceDetailFacadeService.java b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceDetailFacadeService.java new file mode 100644 index 00000000..cd06c465 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceDetailFacadeService.java @@ -0,0 +1,127 @@ +package com.genius.gitget.challenge.instance.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_JOIN_INSTANCE; +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_QUIT_INSTANCE; + +import com.genius.gitget.challenge.certification.service.GithubService; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.dto.detail.InstanceResponse; +import com.genius.gitget.challenge.instance.dto.detail.JoinRequest; +import com.genius.gitget.challenge.instance.dto.detail.JoinResponse; +import com.genius.gitget.challenge.instance.dto.detail.LikesInfo; +import com.genius.gitget.challenge.likes.service.LikesService; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.service.ParticipantService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.util.exception.BusinessException; +import java.time.LocalDate; +import org.kohsuke.github.GitHub; +import org.springframework.stereotype.Component; + +@Component +public class InstanceDetailFacadeService implements InstanceDetailFacade { + + private final InstanceService instanceService; + private final FilesManager filesManager; + private final ParticipantService participantService; + private final LikesService likesService; + private final UserService userService; + private final GithubService githubService; + + public InstanceDetailFacadeService(InstanceService instanceService, FilesManager filesManager, + ParticipantService participantService, LikesService likesService, + UserService userService, GithubService githubService) { + this.instanceService = instanceService; + this.filesManager = filesManager; + this.participantService = participantService; + this.likesService = likesService; + this.userService = userService; + this.githubService = githubService; + } + + @Override + public InstanceResponse getInstanceDetailInformation(User user, Long instanceId) { + + // 인스턴스 정보 + Instance instance = instanceService.findInstanceById(instanceId); + + // 파일 객체 생성 + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + + // 좋아요 정보 + LikesInfo likesInfo = likesService.getLikesInfo(user.getId(), instance); + + if (participantService.hasJoinedParticipant(user.getId(), instance.getId())) { + return InstanceResponse.createByEntity(instance, likesInfo, JoinStatus.YES, fileResponse); + } + return InstanceResponse.createByEntity(instance, likesInfo, JoinStatus.NO, fileResponse); + } + + + @Override + public JoinResponse joinNewChallenge(User user, JoinRequest joinRequest) { + + User persistUser = userService.findUserById(user.getId()); + + Instance instance = instanceService.findInstanceById(joinRequest.instanceId()); + + String repository = joinRequest.repository(); + + validateJoinDate(instance, joinRequest.todayDate()); + validateInstanceCondition(persistUser, instance); + validateGithub(persistUser, repository); + + instance.updateParticipantCount(1); + Participant participant = Participant.createDefaultParticipant(repository); + participant.setUserAndInstance(persistUser, instance); + return JoinResponse.createJoinResponse(participantService.save(participant)); + } + + private void validateJoinDate(Instance instance, LocalDate todayDate) { + LocalDate startedDate = instance.getStartedDate().toLocalDate(); + + if (todayDate.isBefore(startedDate)) { + return; + } + throw new BusinessException(CAN_NOT_JOIN_INSTANCE); + } + + private void validateInstanceCondition(User user, Instance instance) { + boolean isParticipated = participantService.hasJoinedParticipant(user.getId(), instance.getId()); + if ((instance.getProgress() == Progress.PREACTIVITY) && !isParticipated) { + return; + } + throw new BusinessException(CAN_NOT_JOIN_INSTANCE); + } + + private void validateGithub(User user, String repository) { + GitHub gitHub = githubService.getGithubConnection(user); + String repositoryFullName = githubService.getRepoFullName(gitHub, repository); + githubService.validateGithubRepository(gitHub, repositoryFullName); + } + + @Override + public JoinResponse quitChallenge(User user, Long instanceId) { + Instance instance = instanceService.findInstanceById(instanceId); + Participant participant = participantService.findByJoinInfo(user.getId(), instanceId); + + if (instance.getProgress() == Progress.DONE) { + throw new BusinessException(CAN_NOT_QUIT_INSTANCE); + } + + if (instance.getProgress() == Progress.PREACTIVITY) { + instance.updateParticipantCount(-1); + participantService.delete(participant); + return JoinResponse.createQuitResponse(); + } + + instance.updateParticipantCount(-1); + participant.quitChallenge(); + return JoinResponse.createJoinResponse(participant); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/service/InstanceRecommendationService.java b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceRecommendationService.java new file mode 100644 index 00000000..5145fb64 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceRecommendationService.java @@ -0,0 +1,37 @@ +package com.genius.gitget.challenge.instance.service; + +import static com.genius.gitget.challenge.instance.domain.Progress.PREACTIVITY; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.user.domain.User; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class InstanceRecommendationService { + private final InstanceRepository instanceRepository; + + public List getRecommendations(User user) { + String[] userTags = user.getTags().split(","); + List instances = new ArrayList<>(); + for (String userTag : userTags) { + instances.addAll(instanceRepository.findRecommendations(userTag, PREACTIVITY)); + } + return instances.stream().distinct().collect(Collectors.toList()); + } + + public Slice getInstancesByCondition(Pageable pageable) { + return instanceRepository.findPagesByProgress(PREACTIVITY, pageable); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/service/InstanceSearchService.java b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceSearchService.java new file mode 100644 index 00000000..96aabde9 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceSearchService.java @@ -0,0 +1,41 @@ +package com.genius.gitget.challenge.instance.service; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.SearchRepository; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class InstanceSearchService { + private final SearchRepository searchRepository; + private final StringToEnum stringToEnum; + + public Page searchInstances(String keyword, String progress, Pageable pageable) { + boolean isValidProgress = false; + + List progressData = Arrays.stream(Progress.values()).map(Objects::toString).toList(); + + for (String progressCond : progressData) { + if (progressCond.equals(progress)) { + isValidProgress = true; + break; + } + } + if (isValidProgress) { + Progress convertProgress = stringToEnum.convert(progress); + return searchRepository.search(convertProgress, keyword, pageable); + } + return searchRepository.search(null, keyword, pageable); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/instance/service/InstanceService.java b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceService.java new file mode 100644 index 00000000..575ce7f0 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/service/InstanceService.java @@ -0,0 +1,108 @@ +package com.genius.gitget.challenge.instance.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.INSTANCE_NOT_FOUND; +import static com.genius.gitget.global.util.exception.ErrorCode.INVALID_INSTANCE_DATE; +import static com.genius.gitget.global.util.exception.ErrorCode.TOPIC_NOT_FOUND; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.crud.InstanceUpdateDTO; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import java.time.LocalDate; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class InstanceService { + private final InstanceRepository instanceRepository; + private final TopicRepository topicRepository; + private final FilesManager filesManager; + + @NotNull + private String getUuid() { + String uuid = UUID.randomUUID().toString(); + return uuid.replaceAll("-", "").substring(0, 16); + } + + private void validatePeriod(LocalDate startDate, LocalDate completeDate, LocalDate currentDate) { + if (currentDate.isAfter(startDate) || currentDate.isAfter(completeDate)) { + throw new BusinessException(INVALID_INSTANCE_DATE); + } + } + + // 인스턴스 생성 + @Transactional + public Long createInstance(Instance instance, Long id, LocalDate startDate, LocalDate completeDate, + LocalDate currentDate) { + Topic topic = topicRepository.findById(id) + .orElseThrow(() -> new BusinessException(TOPIC_NOT_FOUND)); + + validatePeriod(startDate, completeDate, currentDate); + String uuid = getUuid(); + instance.setInstanceUUID(uuid); + instance.setTopic(topic); + + return instanceRepository.save(instance).getId(); + } + + // 인스턴스 수정 + @Transactional + public Long modifyInstance(Long id, InstanceUpdateDTO instanceUpdateDTO) { + Instance existingInstance = instanceRepository.findById(id) + .orElseThrow(() -> new BusinessException(INSTANCE_NOT_FOUND)); + + existingInstance.updateInstance(instanceUpdateDTO.description(), instanceUpdateDTO.notice(), + instanceUpdateDTO.pointPerPerson(), instanceUpdateDTO.startedDate(), + instanceUpdateDTO.completedDate(), instanceUpdateDTO.certificationMethod()); + + Instance savedInstance = instanceRepository.save(existingInstance); + + return savedInstance.getId(); + } + + // 인스턴스 삭제 + @Transactional + public void removeInstance(Long id) { + Instance instance = instanceRepository.findById(id) + .orElseThrow(() -> new BusinessException(INSTANCE_NOT_FOUND)); + + Files files = instance.getFiles().orElse(null); + Long filesId = files != null ? files.getId() : null; + + if (filesId != null) { + filesManager.deleteFile(filesId); + instance.setFiles(null); + } + instanceRepository.delete(instance); + } + + // 인스턴스 단건 조회 + public Instance findInstanceById(Long id) { + return instanceRepository.findById(id) + .orElseThrow(() -> new BusinessException(INSTANCE_NOT_FOUND)); + } + + // 인스턴스 리스트 조회 + public Page findAllInstances(Pageable pageable) { + return instanceRepository.findAllById(pageable); + } + + // 특정 토픽에 대한 리스트 조회 + public Page getAllInstancesOfSpecificTopic(Pageable pageable, Long id) { + Topic topic = topicRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + return instanceRepository.findInstancesByTopicId(pageable, topic.getId()); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/instance/service/StringToEnum.java b/src/main/java/com/genius/gitget/challenge/instance/service/StringToEnum.java new file mode 100644 index 00000000..acf57075 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/instance/service/StringToEnum.java @@ -0,0 +1,15 @@ +package com.genius.gitget.challenge.instance.service; + +import com.genius.gitget.challenge.instance.domain.Progress; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class StringToEnum implements Converter { + @Override + public Progress convert(String source) { + return Progress.valueOf(source.toUpperCase()); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/controller/LikesController.java b/src/main/java/com/genius/gitget/challenge/likes/controller/LikesController.java new file mode 100644 index 00000000..5eabc5dd --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/controller/LikesController.java @@ -0,0 +1,71 @@ +package com.genius.gitget.challenge.likes.controller; + +import com.genius.gitget.challenge.likes.dto.UserLikesAddRequest; +import com.genius.gitget.challenge.likes.dto.UserLikesAddResponse; +import com.genius.gitget.challenge.likes.dto.UserLikesResponse; +import com.genius.gitget.challenge.likes.facade.LikesFacade; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.exception.SuccessCode; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.PagingResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/profile") +public class LikesController { + private final LikesFacade likesFacade; + + // 좋아요 목록 조회 + @GetMapping("/likes") + public ResponseEntity> getLikesListOfUser( + Pageable pageable, + @GitGetUser User user) { + + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); + Page likesResponses = likesFacade.getLikesList(user, pageRequest); + + return ResponseEntity.ok().body( + new PagingResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), likesResponses) + ); + } + + // 좋아요 목록 추가 + @PostMapping("/likes") + public ResponseEntity> addLikes( + @GitGetUser User user, + @RequestBody UserLikesAddRequest userLikesAddRequest) { + UserLikesAddResponse userLikesAddResponse = likesFacade.addLikes(user, + userLikesAddRequest.getIdentifier(), + userLikesAddRequest.getInstanceId()); + return ResponseEntity.ok().body( + new SingleResponse<>(SuccessCode.CREATED.getStatus(), SuccessCode.CREATED.getMessage(), + userLikesAddResponse) + ); + } + + // 좋아요 목록 삭제 + @DeleteMapping("/likes/{likesId}") + public ResponseEntity deleteLikes(@GitGetUser User user, + @PathVariable(value = "likesId") Long likesId) { + likesFacade.deleteLikes(user, likesId); + return ResponseEntity.ok().body( + new CommonResponse(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage()) + ); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/domain/Likes.java b/src/main/java/com/genius/gitget/challenge/likes/domain/Likes.java new file mode 100644 index 00000000..16e44dff --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/domain/Likes.java @@ -0,0 +1,75 @@ +package com.genius.gitget.challenge.likes.domain; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "likes") +@EntityListeners(AuditingEntityListener.class) +public class Likes { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "likes_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "instance_id") + private Instance instance; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @CreatedDate + @Column(name = "liked_at") + private LocalDateTime likedAt; + + @Builder + public Likes(User user, Instance instance) { + this.instance = instance; + this.user = user; + setUserAndInstance(user, instance); + } + + /*== 연관관계 편의 메서드 ==*/ + public void setUserAndInstance(User user, Instance instance) { + addLikesForUser(user); + addLikesForInstance(instance); + } + + private void addLikesForUser(User user) { + if (!(user.getLikesList().contains(this))) { + user.getLikesList().add(this); + } + this.user = user; + } + + private void addLikesForInstance(Instance instance) { + if (!(instance.getLikesList().contains(this))) { + instance.getLikesList().add(this); + } + this.instance = instance; + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesAddRequest.java b/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesAddRequest.java new file mode 100644 index 00000000..024b08ca --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesAddRequest.java @@ -0,0 +1,16 @@ +package com.genius.gitget.challenge.likes.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +public class UserLikesAddRequest { + private String identifier; + private Long instanceId; + + @Builder + public UserLikesAddRequest(String identifier, Long instanceId) { + this.identifier = identifier; + this.instanceId = instanceId; + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesAddResponse.java b/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesAddResponse.java new file mode 100644 index 00000000..7012d030 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesAddResponse.java @@ -0,0 +1,14 @@ +package com.genius.gitget.challenge.likes.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +public class UserLikesAddResponse { + private Long likesId; + + @Builder + public UserLikesAddResponse(Long likesId) { + this.likesId = likesId; + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesResponse.java b/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesResponse.java new file mode 100644 index 00000000..bb32f3af --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/dto/UserLikesResponse.java @@ -0,0 +1,24 @@ +package com.genius.gitget.challenge.likes.dto; + +import com.genius.gitget.global.file.dto.FileResponse; +import lombok.Builder; +import lombok.Data; + +@Data +public class UserLikesResponse { + private Long likesId; + private Long instanceId; + private String title; + private int pointPerPerson; + private FileResponse fileResponse; + + @Builder + public UserLikesResponse(Long likesId, Long instanceId, String title, + int pointPerPerson, FileResponse fileResponse) { + this.likesId = likesId; + this.instanceId = instanceId; + this.title = title; + this.pointPerPerson = pointPerPerson; + this.fileResponse = fileResponse; + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/facade/LikesFacade.java b/src/main/java/com/genius/gitget/challenge/likes/facade/LikesFacade.java new file mode 100644 index 00000000..418f978e --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/facade/LikesFacade.java @@ -0,0 +1,15 @@ +package com.genius.gitget.challenge.likes.facade; + +import com.genius.gitget.challenge.likes.dto.UserLikesAddResponse; +import com.genius.gitget.challenge.likes.dto.UserLikesResponse; +import com.genius.gitget.challenge.user.domain.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface LikesFacade { + Page getLikesList(User user, Pageable pageable); + + UserLikesAddResponse addLikes(User user, String identifier, Long instanceId); + + void deleteLikes(User user, Long likesId); +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/facade/LikesFacadeService.java b/src/main/java/com/genius/gitget/challenge/likes/facade/LikesFacadeService.java new file mode 100644 index 00000000..d2ae0270 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/facade/LikesFacadeService.java @@ -0,0 +1,69 @@ +package com.genius.gitget.challenge.likes.facade; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.likes.dto.UserLikesAddResponse; +import com.genius.gitget.challenge.likes.dto.UserLikesResponse; +import com.genius.gitget.challenge.likes.service.LikesService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import java.util.LinkedList; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +public class LikesFacadeService implements LikesFacade { + + LikesService likesService; + FilesManager filesManager; + + public LikesFacadeService(LikesService likesService, FilesManager filesManager) { + this.likesService = likesService; + this.filesManager = filesManager; + } + + @Override + public Page getLikesList(User user, Pageable pageable) { + LinkedList userLikesResponses = new LinkedList<>(); + + List likesList = likesService.getLikesList(user); + + for (Likes like : likesList) { + Instance instance = like.getInstance(); + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + UserLikesResponse userLikesResponse = getUserLikesResponse(like, instance, fileResponse); + userLikesResponses.addFirst(userLikesResponse); + } + + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), userLikesResponses.size()); + return new PageImpl<>(userLikesResponses.stream().toList().subList(start, end), pageable, + userLikesResponses.size()); + } + + @Override + public UserLikesAddResponse addLikes(User user, String identifier, Long instanceId) { + Long id = likesService.addLikes(user, identifier, instanceId); + return UserLikesAddResponse.builder() + .likesId(id).build(); + } + + @Override + public void deleteLikes(User user, Long likesId) { + likesService.deleteLikes(likesId); + } + + private UserLikesResponse getUserLikesResponse(Likes like, Instance instance, FileResponse fileResponse) { + return UserLikesResponse.builder() + .likesId(like.getId()) + .instanceId(instance.getId()) + .title(instance.getTitle()) + .pointPerPerson(instance.getPointPerPerson()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/repository/LikesRepository.java b/src/main/java/com/genius/gitget/challenge/likes/repository/LikesRepository.java new file mode 100644 index 00000000..c246b0ad --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/repository/LikesRepository.java @@ -0,0 +1,14 @@ +package com.genius.gitget.challenge.likes.repository; + +import com.genius.gitget.challenge.likes.domain.Likes; +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 LikesRepository extends JpaRepository { + + @Query("select l from Likes l where l.user.id = :userId and l.instance.id = :instanceId") + Optional findSpecificLike(@Param("userId") Long userId, + @Param("instanceId") Long instanceId); +} diff --git a/src/main/java/com/genius/gitget/challenge/likes/service/LikesService.java b/src/main/java/com/genius/gitget/challenge/likes/service/LikesService.java new file mode 100644 index 00000000..19055f54 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/likes/service/LikesService.java @@ -0,0 +1,95 @@ +package com.genius.gitget.challenge.likes.service; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.detail.LikesInfo; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +@Service +public class LikesService { + private final UserRepository userRepository; + private final InstanceRepository instanceRepository; + private final LikesRepository likesRepository; + + public List getLikesList(User user) { + List userList = verifyUser(user); + List likes = new ArrayList<>(); + + for (User userData : userList) { + if (userData.getIdentifier().equals(user.getIdentifier())) { + likes = userData.getLikesList(); + } + } + return likes; + } + + @Transactional + public Long addLikes(User user, String identifier, Long instanceId) { + User comparedUser = compareToUserIdentifier(user, identifier); + List userList = verifyUser(comparedUser); + User findUser = null; + + for (User userObject : userList) { + if (userObject.getIdentifier().equals(identifier)) { + findUser = userObject; + } + } + Instance findInstance = verifyInstance(instanceId); + + Likes likes = Likes.builder() + .instance(findInstance) + .user(findUser) + .build(); + + return likesRepository.save(likes).getId(); + } + + @Transactional + public void deleteLikes(Long likesId) { + Likes findLikes = likesRepository.findById(likesId) + .orElseThrow(() -> new BusinessException(ErrorCode.LIKES_NOT_FOUND)); + + likesRepository.deleteById(findLikes.getId()); + } + + public LikesInfo getLikesInfo(Long userId, Instance instance) { + Optional optionalLikes = likesRepository.findSpecificLike(userId, instance.getId()); + if (optionalLikes.isPresent()) { + Likes likes = optionalLikes.get(); + return LikesInfo.createExist(likes.getId(), instance.getLikesCount()); + } + + return LikesInfo.createNotExist(instance.getLikesCount()); + } + + private List verifyUser(User user) { + return userRepository.findAllByIdentifier(user.getIdentifier()); + } + + private Instance verifyInstance(Long instanceId) { + return instanceRepository.findById(instanceId) + .orElseThrow(() -> new BusinessException(ErrorCode.INSTANCE_NOT_FOUND)); + } + + private User compareToUserIdentifier(User AuthenticatedUser, String identifier) { + if (!(AuthenticatedUser.getIdentifier().equals(identifier))) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + return AuthenticatedUser; + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/controller/MyChallengeController.java b/src/main/java/com/genius/gitget/challenge/myChallenge/controller/MyChallengeController.java new file mode 100644 index 00000000..3ebe05eb --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/controller/MyChallengeController.java @@ -0,0 +1,83 @@ +package com.genius.gitget.challenge.myChallenge.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.myChallenge.dto.DoneResponse; +import com.genius.gitget.challenge.myChallenge.dto.PreActivityResponse; +import com.genius.gitget.challenge.myChallenge.dto.RewardRequest; +import com.genius.gitget.challenge.myChallenge.facade.MyChallengeFacade; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.ListResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/challenges") +@RequiredArgsConstructor +@CrossOrigin +public class MyChallengeController { + private final MyChallengeFacade myChallengeFacade; + + @GetMapping("/my/pre-activity") + public ResponseEntity> getPreActivityChallenges( + @GitGetUser User user + ) { + List preActivityInstances = myChallengeFacade.getPreActivityInstances( + user, DateUtil.convertToKST(LocalDateTime.now())); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), preActivityInstances) + ); + } + + + @GetMapping("/my/activity") + public ResponseEntity> getActivatedChallenges( + @GitGetUser User user + ) { + List activatedInstances = myChallengeFacade.getActivatedInstances( + user, DateUtil.convertToKST(LocalDateTime.now())); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), activatedInstances) + ); + } + + @GetMapping("/my/done") + public ResponseEntity> getDoneChallenges( + @GitGetUser User user + ) { + List doneInstances = myChallengeFacade.getDoneInstances( + user, DateUtil.convertToKST(LocalDateTime.now())); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), doneInstances) + ); + } + + @GetMapping("/reward/{instanceId}") + public ResponseEntity> getRewards( + @GitGetUser User user, + @PathVariable Long instanceId + ) { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + RewardRequest rewardRequest = new RewardRequest(user.getId(), instanceId, kstDate); + DoneResponse doneResponse = myChallengeFacade.getRewards(rewardRequest); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), doneResponse) + ); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/dto/ActivatedResponse.java b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/ActivatedResponse.java new file mode 100644 index 00000000..5ee50427 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/ActivatedResponse.java @@ -0,0 +1,56 @@ +package com.genius.gitget.challenge.myChallenge.dto; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.store.item.dto.OrderResponse; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ActivatedResponse extends OrderResponse { + private Long instanceId; + private String title; + private int pointPerPerson; + private String repository; + private String certificateStatus; + private int numOfPassItem; + private boolean canUsePassItem; + private FileResponse fileResponse; + + @Builder + public ActivatedResponse(Long instanceId, String title, int pointPerPerson, String repository, + String certificateStatus, + int numOfPassItem, boolean canUsePassItem, FileResponse fileResponse) { + this.instanceId = instanceId; + this.title = title; + this.pointPerPerson = pointPerPerson; + this.repository = repository; + this.certificateStatus = certificateStatus; + this.numOfPassItem = numOfPassItem; + this.canUsePassItem = canUsePassItem; + this.fileResponse = fileResponse; + } + + public static ActivatedResponse of(Instance instance, CertificateStatus certificateStatus, + int numOfPassItem, String repository, FileResponse fileResponse) { + boolean canUseItem = checkItemCondition(certificateStatus, numOfPassItem); + + return ActivatedResponse.builder() + .instanceId(instance.getId()) + .title(instance.getTitle()) + .pointPerPerson(instance.getPointPerPerson()) + .repository(repository) + .certificateStatus(certificateStatus.getTag()) + .canUsePassItem(canUseItem) + .numOfPassItem(canUseItem ? numOfPassItem : 0) + .fileResponse(fileResponse) + .build(); + } + + private static boolean checkItemCondition(CertificateStatus certificateStatus, int numOfPassItem) { + return (certificateStatus == CertificateStatus.NOT_YET) && (numOfPassItem > 0); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/dto/DoneResponse.java b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/DoneResponse.java new file mode 100644 index 00000000..449960ab --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/DoneResponse.java @@ -0,0 +1,75 @@ +package com.genius.gitget.challenge.myChallenge.dto; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.domain.RewardStatus; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.store.item.dto.OrderResponse; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DoneResponse extends OrderResponse { + private Long instanceId; + private String title; + private int pointPerPerson; + private JoinResult joinResult; + private boolean canGetReward; + private int numOfPointItem; + private int rewardedPoints; + private double achievementRate; + private FileResponse fileResponse; + + @Builder + public DoneResponse(Long instanceId, String title, int pointPerPerson, JoinResult joinResult, boolean canGetReward, + int numOfPointItem, int rewardedPoints, double achievementRate, FileResponse fileResponse) { + + this.instanceId = instanceId; + this.title = title; + this.pointPerPerson = pointPerPerson; + this.joinResult = joinResult; + this.canGetReward = canGetReward; + this.numOfPointItem = numOfPointItem; + this.rewardedPoints = rewardedPoints; + this.achievementRate = achievementRate; + this.fileResponse = fileResponse; + } + + public static DoneResponse createNotRewarded(Instance instance, + Participant participant, + int numOfPointItem, double achievementRate, + FileResponse fileResponse) { + return DoneResponse.builder() + .title(instance.getTitle()) + .instanceId(instance.getId()) + .pointPerPerson(instance.getPointPerPerson()) + .joinResult(participant.getJoinResult()) + .canGetReward(canGetReward(participant)) + .numOfPointItem(numOfPointItem) + .achievementRate(achievementRate) + .fileResponse(fileResponse) + .build(); + } + + public static DoneResponse createRewarded(Instance instance, Participant participant, + double achievementRate, FileResponse fileResponse) { + return DoneResponse.builder() + .title(instance.getTitle()) + .instanceId(instance.getId()) + .pointPerPerson(instance.getPointPerPerson()) + .joinResult(participant.getJoinResult()) + .canGetReward(false) + .rewardedPoints(participant.getRewardPoints()) + .achievementRate(achievementRate) + .fileResponse(fileResponse) + .build(); + } + + private static boolean canGetReward(Participant participant) { + return (participant.getRewardStatus() == RewardStatus.NO) && + (participant.getJoinResult() == JoinResult.SUCCESS); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/dto/PreActivityResponse.java b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/PreActivityResponse.java new file mode 100644 index 00000000..9ed51312 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/PreActivityResponse.java @@ -0,0 +1,27 @@ +package com.genius.gitget.challenge.myChallenge.dto; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.dto.FileResponse; +import lombok.Builder; + +@Builder +public record PreActivityResponse( + Long instanceId, + String title, + int participantCount, + int pointPerPerson, + int remainDays, + FileResponse fileResponse +) { + + public static PreActivityResponse of(Instance instance, int remainDays, FileResponse fileResponse) { + return PreActivityResponse.builder() + .instanceId(instance.getId()) + .title(instance.getTitle()) + .participantCount(instance.getParticipantCount()) + .pointPerPerson(instance.getPointPerPerson()) + .remainDays(remainDays) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/dto/RewardRequest.java b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/RewardRequest.java new file mode 100644 index 00000000..d2f08b8c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/dto/RewardRequest.java @@ -0,0 +1,14 @@ +package com.genius.gitget.challenge.myChallenge.dto; + +import java.time.LocalDate; + +public record RewardRequest( + Long userId, + Long instanceId, + LocalDate targetDate +) { + + public static RewardRequest of(Long userId, Long instanceId, LocalDate targetDate) { + return new RewardRequest(userId, instanceId, targetDate); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/facade/MyChallengeFacade.java b/src/main/java/com/genius/gitget/challenge/myChallenge/facade/MyChallengeFacade.java new file mode 100644 index 00000000..135153e9 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/facade/MyChallengeFacade.java @@ -0,0 +1,19 @@ +package com.genius.gitget.challenge.myChallenge.facade; + +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.myChallenge.dto.DoneResponse; +import com.genius.gitget.challenge.myChallenge.dto.PreActivityResponse; +import com.genius.gitget.challenge.myChallenge.dto.RewardRequest; +import com.genius.gitget.challenge.user.domain.User; +import java.time.LocalDate; +import java.util.List; + +public interface MyChallengeFacade { + List getPreActivityInstances(User user, LocalDate targetDate); + + List getActivatedInstances(User user, LocalDate targetDate); + + List getDoneInstances(User user, LocalDate targetDate); + + DoneResponse getRewards(RewardRequest rewardRequest); +} diff --git a/src/main/java/com/genius/gitget/challenge/myChallenge/facade/MyChallengeFacadeService.java b/src/main/java/com/genius/gitget/challenge/myChallenge/facade/MyChallengeFacadeService.java new file mode 100644 index 00000000..8af025e5 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/myChallenge/facade/MyChallengeFacadeService.java @@ -0,0 +1,133 @@ +package com.genius.gitget.challenge.myChallenge.facade; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; +import static com.genius.gitget.challenge.participant.domain.RewardStatus.NO; +import static com.genius.gitget.store.item.domain.ItemCategory.CERTIFICATION_PASSER; +import static com.genius.gitget.store.item.domain.ItemCategory.POINT_MULTIPLIER; + +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.service.CertificationService; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.myChallenge.dto.DoneResponse; +import com.genius.gitget.challenge.myChallenge.dto.PreActivityResponse; +import com.genius.gitget.challenge.myChallenge.dto.RewardRequest; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.service.ParticipantService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.service.ItemService; +import com.genius.gitget.store.item.service.OrdersService; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MyChallengeFacadeService implements MyChallengeFacade { + private final FilesManager filesManager; + private final ParticipantService participantService; + private final CertificationService certificationService; + private final ItemService itemService; + private final OrdersService ordersService; + + + @Override + public List getPreActivityInstances(User user, LocalDate targetDate) { + List preActivity = new ArrayList<>(); + List participants = participantService.findJoinedByProgress(user.getId(), Progress.PREACTIVITY); + + for (Participant participant : participants) { + Instance instance = participant.getInstance(); + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + int remainDays = DateUtil.getRemainDaysToStart(participant.getStartedDate(), targetDate); + + PreActivityResponse preActivityResponse = PreActivityResponse.of(instance, remainDays, fileResponse); + preActivity.add(preActivityResponse); + } + + return preActivity; + } + + @Override + public List getActivatedInstances(User user, LocalDate targetDate) { + List activated = new ArrayList<>(); + List participants = participantService.findJoinedByProgress(user.getId(), Progress.ACTIVITY); + + for (Participant participant : participants) { + Instance instance = participant.getInstance(); + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + + Certification certification = certificationService.findOrSave(participant, NOT_YET, targetDate); + + Item item = itemService.findAllByCategory(CERTIFICATION_PASSER).get(0); + int numOfPassItem = ordersService.countNumOfItem(user, item.getId()); + + ActivatedResponse activatedResponse = ActivatedResponse.of( + instance, certification.getCertificationStatus(), + numOfPassItem, participant.getRepositoryName(), fileResponse + ); + activatedResponse.setItemId(item.getId()); + activated.add(activatedResponse); + } + return activated; + } + + @Override + public List getDoneInstances(User user, LocalDate targetDate) { + List done = new ArrayList<>(); + List participants = participantService.findDoneInstances(user.getId()); + + for (Participant participant : participants) { + Instance instance = participant.getInstance(); + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + double achievementRate = certificationService.getAchievementRate(instance, participant.getId(), + targetDate); + + // 포인트를 아직 수령하지 않았을 때 + if (participant.getRewardStatus() == NO) { + Item item = itemService.findAllByCategory(POINT_MULTIPLIER).get(0); + int numOfPassItem = ordersService.countNumOfItem(user, item.getId()); + DoneResponse doneResponse = DoneResponse.createNotRewarded( + instance, participant, numOfPassItem, achievementRate, fileResponse); + doneResponse.setItemId(item.getId()); + done.add(doneResponse); + continue; + } + + // 포인트를 수령했을 때 + DoneResponse doneResponse = DoneResponse.createRewarded( + instance, participant, achievementRate, fileResponse); + done.add(doneResponse); + } + + return done; + } + + @Override + @Transactional + public DoneResponse getRewards(RewardRequest rewardRequest) { + Participant participant = participantService.findByJoinInfo( + rewardRequest.userId(), rewardRequest.instanceId() + ); + Instance instance = participant.getInstance(); + + FileResponse fileResponse = filesManager.convertToFileResponse(instance.getFiles()); + + int rewardPoints = instance.getPointPerPerson(); + participantService.getRewards(participant, rewardPoints); + + double achievementRate = certificationService.getAchievementRate(instance, participant.getId(), + rewardRequest.targetDate()); + + return DoneResponse.createRewarded(instance, participant, achievementRate, fileResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/challenge/participant/domain/JoinResult.java b/src/main/java/com/genius/gitget/challenge/participant/domain/JoinResult.java new file mode 100644 index 00000000..590fbd6e --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/participant/domain/JoinResult.java @@ -0,0 +1,11 @@ +package com.genius.gitget.challenge.participant.domain; + +import lombok.Getter; + +@Getter +public enum JoinResult { + READY, + PROCESSING, + FAIL, + SUCCESS +} diff --git a/src/main/java/com/genius/gitget/challenge/participant/domain/JoinStatus.java b/src/main/java/com/genius/gitget/challenge/participant/domain/JoinStatus.java new file mode 100644 index 00000000..aba5972c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/participant/domain/JoinStatus.java @@ -0,0 +1,5 @@ +package com.genius.gitget.challenge.participant.domain; + +public enum JoinStatus { + NO, YES +} diff --git a/src/main/java/com/genius/gitget/challenge/participant/domain/Participant.java b/src/main/java/com/genius/gitget/challenge/participant/domain/Participant.java new file mode 100644 index 00000000..09cfba9f --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/participant/domain/Participant.java @@ -0,0 +1,145 @@ +package com.genius.gitget.challenge.participant.domain; + +import static com.genius.gitget.challenge.participant.domain.JoinResult.SUCCESS; +import static com.genius.gitget.challenge.participant.domain.RewardStatus.YES; + +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +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 jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@Table(name = "participant") +public class Participant { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "participant_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "instance_id") + private Instance instance; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "participant", cascade = CascadeType.ALL, orphanRemoval = true) + private List certificationList = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @NotNull + @ColumnDefault("'YES'") + private JoinStatus joinStatus; + + @Enumerated(EnumType.STRING) + private JoinResult joinResult; + + private String repositoryName; + + @Enumerated(EnumType.STRING) + @ColumnDefault("'NO'") + private RewardStatus rewardStatus; + + private int rewardPoints; + + @Builder + private Participant(JoinStatus joinStatus, JoinResult joinResult, String repositoryName, + RewardStatus rewardStatus, int rewardPoints) { + this.joinStatus = joinStatus; + this.joinResult = joinResult; + this.repositoryName = repositoryName; + this.rewardStatus = rewardStatus; + this.rewardPoints = rewardPoints; + } + + public static Participant createDefaultParticipant(String repositoryName) { + return Participant.builder() + .joinStatus(JoinStatus.YES) + .joinResult(JoinResult.READY) + .repositoryName(repositoryName) + .rewardStatus(RewardStatus.NO) + .rewardPoints(0) + .build(); + } + + //=== 비지니스 로직 ===// + public void quitChallenge() { + this.joinStatus = JoinStatus.NO; + this.joinResult = JoinResult.FAIL; + } + + public void getRewards(int rewardPoints) { + this.rewardStatus = RewardStatus.YES; + this.rewardPoints = rewardPoints; + } + + public void updateJoinResult(JoinResult joinResult) { + this.joinResult = joinResult; + } + + public void updateRepository(String repository) { + this.repositoryName = repository; + } + + public LocalDate getStartedDate() { + return this.getInstance().getStartedDate().toLocalDate(); + } + + public void validateRewardCondition() { + if (this.getJoinResult() != SUCCESS) { + throw new BusinessException(ErrorCode.CAN_NOT_GET_REWARDS); + } + if (this.getRewardStatus() == YES) { + throw new BusinessException(ErrorCode.ALREADY_REWARDED); + } + } + + + /*== 연관관계 편의 메서드 ==*/ + public void setUserAndInstance(User user, Instance instance) { + addParticipantInfoForUser(user); + addParticipantInfoForInstance(instance); + } + + private void addParticipantInfoForUser(User user) { + this.user = user; + if (!(user.getParticipantList().contains(this))) { + user.getParticipantList().add(this); + } + } + + private void addParticipantInfoForInstance(Instance instance) { + this.instance = instance; + if (!(instance.getParticipantList().contains(this))) { + instance.getParticipantList().add(this); + } + } +} diff --git a/src/main/java/com/genius/gitget/challenge/participant/domain/RewardStatus.java b/src/main/java/com/genius/gitget/challenge/participant/domain/RewardStatus.java new file mode 100644 index 00000000..54f59dd3 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/participant/domain/RewardStatus.java @@ -0,0 +1,5 @@ +package com.genius.gitget.challenge.participant.domain; + +public enum RewardStatus { + NO, YES +} diff --git a/src/main/java/com/genius/gitget/challenge/participant/repository/ParticipantRepository.java b/src/main/java/com/genius/gitget/challenge/participant/repository/ParticipantRepository.java new file mode 100644 index 00000000..41160945 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/participant/repository/ParticipantRepository.java @@ -0,0 +1,29 @@ +package com.genius.gitget.challenge.participant.repository; + +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import java.util.List; +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 ParticipantRepository extends JpaRepository { + + @Query("select p from Participant p where p.instance.id = :instanceId and p.user.id = :userId") + Optional findByJoinInfo(@Param("userId") Long userId, + @Param("instanceId") Long instanceId); + + @Query("select p from Participant p where p.user.id = :userId and p.instance.progress = :progress and p.joinStatus = :joinStatus") + List findAllByStatus(@Param("userId") Long userId, + @Param("progress") Progress progress, + @Param("joinStatus") JoinStatus joinStatus); + + @Query("select p from Participant p where p.instance.id = :instanceId and p.joinStatus = :joinStatus") + Slice findAllByInstanceId(@Param("instanceId") Long instanceId, + @Param("joinStatus") JoinStatus joinStatus, + Pageable pageable); +} diff --git a/src/main/java/com/genius/gitget/challenge/participant/service/ParticipantService.java b/src/main/java/com/genius/gitget/challenge/participant/service/ParticipantService.java new file mode 100644 index 00000000..e33c23fe --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/participant/service/ParticipantService.java @@ -0,0 +1,94 @@ +package com.genius.gitget.challenge.participant.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.PARTICIPANT_NOT_FOUND; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.exception.BusinessException; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ParticipantService { + private final ParticipantRepository participantRepository; + + @Transactional + public Participant save(Participant participant) { + return participantRepository.save(participant); + } + + @Transactional + public void delete(Participant participant) { + participantRepository.delete(participant); + } + + public Participant findByJoinInfo(Long userId, Long instanceId) { + return participantRepository.findByJoinInfo(userId, instanceId) + .orElseThrow(() -> new BusinessException(PARTICIPANT_NOT_FOUND)); + } + + public Participant findById(Long participantInfoId) { + return participantRepository.findById(participantInfoId) + .orElseThrow(() -> new BusinessException(PARTICIPANT_NOT_FOUND)); + } + + public Slice findAllByInstanceId(Long userId, Long instanceId, Pageable pageable) { + Slice participants = participantRepository.findAllByInstanceId(instanceId, JoinStatus.YES, + pageable); + List filtered = participants.stream() + .filter(participant -> participant.getUser().getId() != userId) + .toList(); + + return new SliceImpl<>(filtered, pageable, participants.hasNext()); + } + + public Instance getInstanceById(Long participantId) { + return participantRepository.findById(participantId) + .orElseThrow(() -> new BusinessException(PARTICIPANT_NOT_FOUND)) + .getInstance(); + } + + public List findJoinedByProgress(Long userId, Progress progress) { + return participantRepository.findAllByStatus(userId, progress, JoinStatus.YES); + } + + public List findDoneInstances(Long userId) { + List doneInstances = new ArrayList<>(); + // 실패한 챌린지 리스트 + doneInstances.addAll(participantRepository.findAllByStatus(userId, Progress.ACTIVITY, JoinStatus.NO)); + + // 성공한 챌린지 리스트 + doneInstances.addAll(participantRepository.findAllByStatus(userId, Progress.DONE, JoinStatus.YES)); + + return doneInstances; + } + + public boolean hasJoinedParticipant(Long userId, Long instanceId) { + return participantRepository.findByJoinInfo(userId, instanceId) + .map(participant -> participant.getJoinStatus() != JoinStatus.NO) + .orElse(false); + } + + @Transactional + public void getRewards(Participant participant, int rewardPoints) { + User user = participant.getUser(); + + participant.validateRewardCondition(); + user.updatePoints((long) rewardPoints); + participant.getRewards(rewardPoints); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/report/controller/ReportController.java b/src/main/java/com/genius/gitget/challenge/report/controller/ReportController.java new file mode 100644 index 00000000..40d15522 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/report/controller/ReportController.java @@ -0,0 +1,7 @@ +package com.genius.gitget.challenge.report.controller; + +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ReportController { +} diff --git a/src/main/java/com/genius/gitget/challenge/report/domain/Report.java b/src/main/java/com/genius/gitget/challenge/report/domain/Report.java new file mode 100644 index 00000000..e3b857d5 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/report/domain/Report.java @@ -0,0 +1,31 @@ +package com.genius.gitget.challenge.report.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 lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "report") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Report { + // TODO PR URL 주소 및 효율적인 방법 조사해보기 -> 1차 배포 후 진행으로 변경 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + private String reporter; + + private String receiver; + + private String reportUrl; + + private String reportReason; +} diff --git a/src/main/java/com/genius/gitget/challenge/report/repository/ReportRepository.java b/src/main/java/com/genius/gitget/challenge/report/repository/ReportRepository.java new file mode 100644 index 00000000..591c5d1b --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/report/repository/ReportRepository.java @@ -0,0 +1,7 @@ +package com.genius.gitget.challenge.report.repository; + +import com.genius.gitget.challenge.report.domain.Report; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { +} diff --git a/src/main/java/com/genius/gitget/challenge/report/service/ReportService.java b/src/main/java/com/genius/gitget/challenge/report/service/ReportService.java new file mode 100644 index 00000000..37662cc3 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/report/service/ReportService.java @@ -0,0 +1,7 @@ +package com.genius.gitget.challenge.report.service; + +import org.springframework.stereotype.Service; + +@Service +public class ReportService { +} diff --git a/src/main/java/com/genius/gitget/challenge/user/controller/UserController.java b/src/main/java/com/genius/gitget/challenge/user/controller/UserController.java new file mode 100644 index 00000000..924457a6 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/controller/UserController.java @@ -0,0 +1,42 @@ +package com.genius.gitget.challenge.user.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.CREATED; +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.user.dto.SignupRequest; +import com.genius.gitget.challenge.user.facade.UserFacade; +import com.genius.gitget.global.security.dto.SignupResponse; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class UserController { + private final UserFacade userFacade; + + @GetMapping("/auth/check-nickname") + public ResponseEntity checkNicknameDuplicate(@RequestParam(value = "nickname") String nickname) { + userFacade.isNicknameDuplicate(nickname); + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } + + @PostMapping("/auth/signup") + public ResponseEntity> signup(@RequestBody SignupRequest signupRequest) { + SignupResponse signupResponse = userFacade.signup(signupRequest); + + return ResponseEntity.ok().body( + new SingleResponse<>(CREATED.getStatus(), CREATED.getMessage(), signupResponse) + ); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/user/domain/Role.java b/src/main/java/com/genius/gitget/challenge/user/domain/Role.java new file mode 100644 index 00000000..938f643c --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/domain/Role.java @@ -0,0 +1,15 @@ +package com.genius.gitget.challenge.user.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + NOT_REGISTERED("ROLE_NOT_REGISTERED", "회원가입 이전 사용자"), + USER("ROLE_USER", "일반 사용자"), + ADMIN("ROLE_ADMIN", "관리자"); + + private final String key; + private final String title; +} diff --git a/src/main/java/com/genius/gitget/challenge/user/domain/User.java b/src/main/java/com/genius/gitget/challenge/user/domain/User.java new file mode 100644 index 00000000..b7367354 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/domain/User.java @@ -0,0 +1,152 @@ +package com.genius.gitget.challenge.user.domain; + +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.global.file.domain.FileHolder; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.domain.BaseTimeEntity; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.payment.domain.Payment; +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.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +public class User extends BaseTimeEntity implements FileHolder { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "files_id") + private Files files; + + @OneToMany(mappedBy = "user") + private List likesList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List participantList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List payment = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List ordersList = new ArrayList<>(); + + @NotNull + @Enumerated(EnumType.STRING) + private ProviderInfo providerInfo; + + @NotNull + private String identifier; + + @NotNull + @Enumerated(EnumType.STRING) + private Role role; + + @Column(unique = true, length = 20) + private String nickname; + + private String tags; + + @Column(length = 100) + private String information; + + @Column(columnDefinition = "TEXT") + private String githubToken; + + @ColumnDefault(value = "0") + private Long point = 0L; + + @Builder + public User(ProviderInfo providerInfo, String identifier, Role role, String nickname, String information, + String tags) { + this.providerInfo = providerInfo; + this.identifier = identifier; + this.role = role; + this.nickname = nickname; + this.tags = tags; + this.information = information; + } + + //=== 비지니스 로직 ===// + public void updateUserInformation(String nickname, String information) { + this.nickname = nickname; + this.information = information; + } + + public void updateUserTags(String tags) { + this.tags = tags; + } + + public void updateRole(Role role) { + this.role = role; + } + + public void updateGithubPersonalToken(String encryptedToken) { + this.githubToken = encryptedToken; + } + + public void hasEnoughPoint(int cost) { + if (this.point < cost) { + throw new BusinessException(ErrorCode.NOT_ENOUGH_POINT); + } + } + + public long updatePoints(Long amount) { + this.point += amount; + return this.point; + } + + public boolean isRegistered() { + return this.role != Role.NOT_REGISTERED; + } + + @Override + public Optional getFiles() { + return Optional.ofNullable(this.files); + } + + @Override + public void setFiles(Files files) { + this.files = files; + } + + //=== 연관관계 편의 메서드 ===// + + public void updateUser(String nickname, String information, String tags) { + this.nickname = nickname; + this.information = information; + this.tags = tags; + } + + public void deleteLikesList() { + this.likesList.clear(); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/user/dto/LoginRequest.java b/src/main/java/com/genius/gitget/challenge/user/dto/LoginRequest.java new file mode 100644 index 00000000..8e089e77 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/dto/LoginRequest.java @@ -0,0 +1,11 @@ +package com.genius.gitget.challenge.user.dto; + +import jakarta.validation.constraints.NotEmpty; + +public record LoginRequest( + @NotEmpty + String id, + @NotEmpty + String password +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/user/dto/SignupRequest.java b/src/main/java/com/genius/gitget/challenge/user/dto/SignupRequest.java new file mode 100644 index 00000000..4043e806 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/dto/SignupRequest.java @@ -0,0 +1,13 @@ +package com.genius.gitget.challenge.user.dto; + +import java.util.List; +import lombok.Builder; + +@Builder +public record SignupRequest( + String identifier, + String nickname, + String information, + List interest +) { +} diff --git a/src/main/java/com/genius/gitget/challenge/user/dto/UserProfileInfo.java b/src/main/java/com/genius/gitget/challenge/user/dto/UserProfileInfo.java new file mode 100644 index 00000000..4c213c81 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/dto/UserProfileInfo.java @@ -0,0 +1,15 @@ +package com.genius.gitget.challenge.user.dto; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.file.dto.FileResponse; + +public record UserProfileInfo( + Long userId, + String nickname, + Long frameId, + FileResponse fileResponse +) { + public static UserProfileInfo createByEntity(User user, Long frameId, FileResponse fileResponse) { + return new UserProfileInfo(user.getId(), user.getNickname(), frameId, fileResponse); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/user/facade/UserFacade.java b/src/main/java/com/genius/gitget/challenge/user/facade/UserFacade.java new file mode 100644 index 00000000..44f3974f --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/facade/UserFacade.java @@ -0,0 +1,19 @@ +package com.genius.gitget.challenge.user.facade; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.dto.LoginRequest; +import com.genius.gitget.challenge.user.dto.SignupRequest; +import com.genius.gitget.global.security.dto.AuthResponse; +import com.genius.gitget.global.security.dto.SignupResponse; + +public interface UserFacade { + void isNicknameDuplicate(String nickname); + + SignupResponse signup(SignupRequest signupRequest); + + AuthResponse getUserAuthInfo(String identifier); + + User getAuthUser(String identifier); + + User getGuestUser(LoginRequest loginRequest); +} diff --git a/src/main/java/com/genius/gitget/challenge/user/facade/UserFacadeService.java b/src/main/java/com/genius/gitget/challenge/user/facade/UserFacadeService.java new file mode 100644 index 00000000..282a2232 --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/facade/UserFacadeService.java @@ -0,0 +1,97 @@ +package com.genius.gitget.challenge.user.facade; + +import static com.genius.gitget.global.util.exception.ErrorCode.ALREADY_REGISTERED; +import static com.genius.gitget.global.util.exception.ErrorCode.DUPLICATED_NICKNAME; +import static com.genius.gitget.global.util.exception.ErrorCode.MEMBER_NOT_FOUND; +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_AUTHENTICATED_USER; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.dto.LoginRequest; +import com.genius.gitget.challenge.user.dto.SignupRequest; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.security.dto.AuthResponse; +import com.genius.gitget.global.security.dto.SignupResponse; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.service.OrdersService; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserFacadeService implements UserFacade { + private final UserService userService; + private final OrdersService ordersService; + + @Value("${guest.id}") + private String guest_id; + + @Value("${guest.password}") + private String guest_password; + + @Value("${admin.githubId}") + private List adminIds; + + @Override + public void isNicknameDuplicate(String nickname) { + String target = nickname.trim(); + if (userService.findByNickname(target).isPresent()) { + throw new BusinessException(DUPLICATED_NICKNAME); + } + } + + @Override + @Transactional + public SignupResponse signup(SignupRequest signupRequest) { + User user = userService.findByIdentifier(signupRequest.identifier()); + + if (user.getRole() != Role.NOT_REGISTERED) { + throw new BusinessException(ALREADY_REGISTERED); + } + + String interest = String.join(",", signupRequest.interest()); + user.updateUser(signupRequest.nickname(), signupRequest.information(), interest); + updateRole(user); + + return SignupResponse.of(user.getId(), user.getIdentifier()); + } + + private void updateRole(User user) { + if (adminIds.contains(user.getIdentifier())) { + user.updateRole(Role.ADMIN); + return; + } + user.updateRole(Role.USER); + } + + @Override + public AuthResponse getUserAuthInfo(String identifier) { + User user = userService.findByIdentifier(identifier); + Item usingFrame = ordersService.getUsingFrameItem(user.getId()); + return new AuthResponse(user.getRole(), usingFrame.getIdentifier()); + } + + @Override + public User getAuthUser(String identifier) { + User user = userService.findByIdentifier(identifier); + if (!user.isRegistered()) { + throw new BusinessException(NOT_AUTHENTICATED_USER); + } + return user; + } + + @Override + public User getGuestUser(LoginRequest loginRequest) { + if (!Objects.equals(loginRequest.id(), guest_id) || !Objects.equals(loginRequest.password(), guest_password)) { + throw new BusinessException(MEMBER_NOT_FOUND); + } + + return userService.findByIdentifier(guest_id); + } +} diff --git a/src/main/java/com/genius/gitget/challenge/user/repository/UserRepository.java b/src/main/java/com/genius/gitget/challenge/user/repository/UserRepository.java new file mode 100644 index 00000000..da00c0bf --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/repository/UserRepository.java @@ -0,0 +1,23 @@ +package com.genius.gitget.challenge.user.repository; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.security.constants.ProviderInfo; +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 UserRepository extends JpaRepository { + Optional findByIdentifier(String identifier); + + @Query("select u from User u where u.identifier = :identifier") + List findAllByIdentifier(String identifier); + + Optional findByNickname(String nickname); + + @Query("select u from User u where u.identifier = :identifier and u.providerInfo = :providerInfo") + Optional findByOAuthInfo(@Param("identifier") String identifier, + @Param("providerInfo") ProviderInfo providerInfo); + +} diff --git a/src/main/java/com/genius/gitget/challenge/user/service/UserService.java b/src/main/java/com/genius/gitget/challenge/user/service/UserService.java new file mode 100644 index 00000000..01e63d4b --- /dev/null +++ b/src/main/java/com/genius/gitget/challenge/user/service/UserService.java @@ -0,0 +1,74 @@ +package com.genius.gitget.challenge.user.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_TOKEN_NOT_FOUND; +import static com.genius.gitget.global.util.exception.ErrorCode.MEMBER_NOT_FOUND; + +import com.genius.gitget.challenge.certification.util.EncryptUtil; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.signout.Signout; +import com.genius.gitget.signout.SignoutRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + private final SignoutRepository signoutRepository; + private final EncryptUtil encryptUtil; + + + public User findUserById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + } + + public User findByIdentifier(String identifier) { + return userRepository.findByIdentifier(identifier) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + } + + public Optional findByNickname(String nickname) { + return userRepository.findByNickname(nickname); + } + + @Transactional + public Long save(User user) { + return userRepository.saveAndFlush(user).getId(); + } + + + @Transactional + public void delete(Long userId, String identifier, String reason) { + userRepository.deleteById(userId); + signoutRepository.save( + Signout.builder() + .identifier(identifier) + .reason(reason) + .build()); + } + + // 포인트 조회 + public Long getUserPoint(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + return user.getPoint(); + } + + public String getGithubToken(User user) { + String githubToken = user.getGithubToken(); + if (githubToken == null || githubToken.isEmpty() || githubToken.isBlank()) { + throw new BusinessException(GITHUB_TOKEN_NOT_FOUND); + } + return encryptUtil.decrypt(githubToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/file/controller/FileTestController.java b/src/main/java/com/genius/gitget/global/file/controller/FileTestController.java new file mode 100644 index 00000000..f6ac8c7b --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/controller/FileTestController.java @@ -0,0 +1,103 @@ +package com.genius.gitget.global.file.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FileService; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import java.util.Optional; +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.PatchMapping; +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 org.springframework.web.multipart.MultipartFile; + +/** + * FileManager의 기능을 별도로 테스트하기 위해 생성한 컨트롤러입니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/file/test") +public class FileTestController { + private final FilesManager filesManager; + private final FileService fileService; + + + @GetMapping("/{fileId}") + public ResponseEntity> download( + @PathVariable Long fileId + ) { + Files files = filesManager.findById(fileId); + FileResponse fileResponse = filesManager.convertToFileResponse(Optional.ofNullable(files)); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), fileResponse) + ); + } + + @PostMapping + public ResponseEntity> upload( + @RequestParam("files") MultipartFile multipartFile, + @RequestParam("type") String type + ) { + FileType fileType = FileType.findType(type); + Files files = filesManager.uploadFile(multipartFile, fileType); + String accessURI = fileService.getFileAccessURI(files); + FileResponse fileResponse = FileResponse.createExistFile(files.getId(), accessURI); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), fileResponse) + ); + } + + @PatchMapping("/{fileId}") + public ResponseEntity> update( + @PathVariable Long fileId, + @RequestParam("files") MultipartFile multipartFile) { + Files files = filesManager.updateFile(fileId, multipartFile); + String accessURI = fileService.getFileAccessURI(files); + FileResponse fileResponse = FileResponse.createExistFile(files.getId(), accessURI); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), fileResponse) + ); + } + + @PostMapping("/{fileId}") + public ResponseEntity> copy( + @PathVariable Long fileId, + @RequestParam("type") String type) { + + FileType fileType = FileType.findType(type); + Files files = filesManager.findById(fileId); + Files copiedFile = filesManager.copyFile(files, fileType); + + String accessURI = fileService.getFileAccessURI(copiedFile); + FileResponse fileResponse = FileResponse.createExistFile(copiedFile.getId(), accessURI); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), fileResponse) + ); + } + + @DeleteMapping("/{fileId}") + public ResponseEntity delete( + @PathVariable Long fileId + ) { + filesManager.deleteFile(fileId); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/controller/FilesController.java b/src/main/java/com/genius/gitget/global/file/controller/FilesController.java new file mode 100644 index 00000000..2605d665 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/controller/FilesController.java @@ -0,0 +1,66 @@ +package com.genius.gitget.global.file.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.global.file.domain.FileHolder; +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FileHolderFinder; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/file") +public class FilesController { + private final FileHolderFinder finder; + private final FilesManager filesManager; + + + @PostMapping("/{id}") + public ResponseEntity> uploadFile( + @PathVariable Long id, + @RequestParam("type") String type, + @RequestParam(value = "files", required = false) MultipartFile multipartFile + ) { + FileType fileType = FileType.findType(type); + FileHolder fileHolder = finder.findByInfo(id, fileType); + Files files; + + files = filesManager.uploadFile(fileHolder, multipartFile, fileType); + FileResponse fileResponse = filesManager.convertToFileResponse(Optional.ofNullable(files)); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), fileResponse) + ); + } + + @PatchMapping("/{id}") + public ResponseEntity> updateFile( + @PathVariable Long id, + @RequestParam("type") String type, + @RequestParam("files") MultipartFile multipartFile + ) { + FileType fileType = FileType.findType(type); + FileHolder fileHolder = finder.findByInfo(id, fileType); + Files files = filesManager.updateFile(fileHolder.getFiles(), multipartFile); + FileResponse fileResponse = filesManager.convertToFileResponse(Optional.ofNullable(files)); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), fileResponse) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/file/domain/FileHolder.java b/src/main/java/com/genius/gitget/global/file/domain/FileHolder.java new file mode 100644 index 00000000..3618a67f --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/domain/FileHolder.java @@ -0,0 +1,9 @@ +package com.genius.gitget.global.file.domain; + +import java.util.Optional; + +public interface FileHolder { + Optional getFiles(); + + void setFiles(Files files); +} diff --git a/src/main/java/com/genius/gitget/global/file/domain/FileType.java b/src/main/java/com/genius/gitget/global/file/domain/FileType.java new file mode 100644 index 00000000..d61d2fab --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/domain/FileType.java @@ -0,0 +1,27 @@ +package com.genius.gitget.global.file.domain; + +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_SUPPORTED_IMAGE_TYPE; + +import com.genius.gitget.global.util.exception.BusinessException; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum FileType { + PROFILE("profile/"), + TOPIC("topic/"), + INSTANCE("instance/"), + PET("pet/"); + + private final String path; + + public static FileType findType(String targetType) { + String lowerTargetType = targetType.toLowerCase(); + return Arrays.stream(FileType.values()) + .filter(type -> type.path.contains(lowerTargetType)) + .findFirst() + .orElseThrow(() -> new BusinessException(NOT_SUPPORTED_IMAGE_TYPE)); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/domain/Files.java b/src/main/java/com/genius/gitget/global/file/domain/Files.java new file mode 100644 index 00000000..b988603b --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/domain/Files.java @@ -0,0 +1,59 @@ +package com.genius.gitget.global.file.domain; + +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.file.dto.UpdateDTO; +import com.genius.gitget.global.util.domain.BaseTimeEntity; +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Files extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "files_id") + private Long id; + + @Enumerated(value = EnumType.STRING) + private FileType fileType; + + private String originalFilename; + + private String savedFilename; + + private String fileURI; + + @Builder + public Files(FileType fileType, String originalFilename, String savedFilename, String fileURI) { + this.fileType = fileType; + this.originalFilename = originalFilename; + this.savedFilename = savedFilename; + this.fileURI = fileURI; + } + + public static Files create(FileDTO fileDTO) { + return Files.builder() + .originalFilename(fileDTO.originalFilename()) + .savedFilename(fileDTO.savedFilename()) + .fileType(fileDTO.fileType()) + .fileURI(fileDTO.fileURI()) + .build(); + } + + //== 비지니스 로직 ==// + public void updateFiles(UpdateDTO updateDTO) { + this.originalFilename = updateDTO.originalFilename(); + this.savedFilename = updateDTO.savedFilename(); + this.fileURI = updateDTO.fileURI(); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/dto/CopyDTO.java b/src/main/java/com/genius/gitget/global/file/dto/CopyDTO.java new file mode 100644 index 00000000..b1e7e711 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/dto/CopyDTO.java @@ -0,0 +1,12 @@ +package com.genius.gitget.global.file.dto; + +import com.genius.gitget.global.file.domain.FileType; +import lombok.Builder; + +@Builder +public record CopyDTO(FileType fileType, + String originalFilename, + String savedFilename, + String fileURI, + String folderURI) { +} diff --git a/src/main/java/com/genius/gitget/global/file/dto/FileDTO.java b/src/main/java/com/genius/gitget/global/file/dto/FileDTO.java new file mode 100644 index 00000000..c73d4f6c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/dto/FileDTO.java @@ -0,0 +1,23 @@ +package com.genius.gitget.global.file.dto; + +import com.genius.gitget.global.file.domain.FileType; +import lombok.Builder; + +/** + * FileDTO는 Files 객체 생성에 필요한 값들을 담어서 전달하는 역할을 합니다. + * + * @param fileType 저장하고자하는 이미지의 타입 + * (TOPIC, INSTANCE, PROFILE 중 택1) + * @param originalFilename 사용자로부터 받은 이미지의 이름 + * (ex: sky.jpeg) + * @param savedFilename 각 이미지를 식별하기 위해 UUID를 부여하여 만든 이미지의 이름 + * (ex:10ab2c6f-77d7-435e-96f0-e75b67213528.jpeg) + * @param fileURI 이미지가 저장되는 경로 + * (ex: /Users/seonghuiyeon/GitGet/images/topic/10ab2c6f-77d7-435e-96f0-e75b67213528.jpeg) + */ +@Builder +public record FileDTO(FileType fileType, + String originalFilename, + String savedFilename, + String fileURI) { +} diff --git a/src/main/java/com/genius/gitget/global/file/dto/FileEnv.java b/src/main/java/com/genius/gitget/global/file/dto/FileEnv.java new file mode 100644 index 00000000..b8a7d667 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/dto/FileEnv.java @@ -0,0 +1,19 @@ +package com.genius.gitget.global.file.dto; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +public class FileEnv { + private static Environment environment; + + @Autowired + public FileEnv(Environment env) { + environment = env; + } + + public static String getFileEnvironment() { + return environment.getProperty("file.mode").toUpperCase(); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/file/dto/FileResponse.java b/src/main/java/com/genius/gitget/global/file/dto/FileResponse.java new file mode 100644 index 00000000..793b51c4 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/dto/FileResponse.java @@ -0,0 +1,15 @@ +package com.genius.gitget.global.file.dto; + +public record FileResponse( + Long fileId, + String source, + String environment) { + + public static FileResponse createExistFile(Long filesId, String accessURI) { + return new FileResponse(filesId, accessURI, FileEnv.getFileEnvironment()); + } + + public static FileResponse createNotExistFile() { + return new FileResponse(0L, "", FileEnv.getFileEnvironment()); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/dto/UpdateDTO.java b/src/main/java/com/genius/gitget/global/file/dto/UpdateDTO.java new file mode 100644 index 00000000..adbbe011 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/dto/UpdateDTO.java @@ -0,0 +1,29 @@ +package com.genius.gitget.global.file.dto; + +import lombok.Builder; + +/** + * UpdateDTO는 Files 객체의 갱신에 필요한 값들을 담는 객체입니다. + * + * @param originalFilename 사용자로부터 받은 이미지의 이름 + * (ex: sky.jpeg) + * @param savedFilename 각 이미지를 식별하기 위해 UUID를 부여하여 만든 이미지의 이름 + * (ex:10ab2c6f-77d7-435e-96f0-e75b67213528.jpeg) + * @param fileURI 이미지가 저장되는 경로 + * (ex: /Users/seonghuiyeon/GitGet/images/topic/10ab2c6f-77d7-435e-96f0-e75b67213528.jpeg) + */ +@Builder +public record UpdateDTO( + String originalFilename, + String savedFilename, + String fileURI +) { + + public static UpdateDTO of(FileDTO fileDTO) { + return UpdateDTO.builder() + .originalFilename(fileDTO.originalFilename()) + .savedFilename(fileDTO.savedFilename()) + .fileURI(fileDTO.fileURI()) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/repository/FilesRepository.java b/src/main/java/com/genius/gitget/global/file/repository/FilesRepository.java new file mode 100644 index 00000000..bfc9a9b5 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/repository/FilesRepository.java @@ -0,0 +1,7 @@ +package com.genius.gitget.global.file.repository; + +import com.genius.gitget.global.file.domain.Files; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FilesRepository extends JpaRepository { +} diff --git a/src/main/java/com/genius/gitget/global/file/service/FileHolderFinder.java b/src/main/java/com/genius/gitget/global/file/service/FileHolderFinder.java new file mode 100644 index 00000000..8fc5ce1e --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/service/FileHolderFinder.java @@ -0,0 +1,42 @@ +package com.genius.gitget.global.file.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.INSTANCE_NOT_FOUND; +import static com.genius.gitget.global.util.exception.ErrorCode.MEMBER_NOT_FOUND; +import static com.genius.gitget.global.util.exception.ErrorCode.TOPIC_NOT_FOUND; + +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.file.domain.FileHolder; +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.util.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FileHolderFinder { + private final UserRepository userRepository; + private final TopicRepository topicRepository; + private final InstanceRepository instanceRepository; + + public FileHolder findByInfo(Long id, FileType fileType) { + switch (fileType) { + case TOPIC -> { + return topicRepository.findById(id) + .orElseThrow(() -> new BusinessException(TOPIC_NOT_FOUND)); + } + case INSTANCE -> { + return instanceRepository.findById(id) + .orElseThrow(() -> new BusinessException(INSTANCE_NOT_FOUND)); + } + case PROFILE -> { + return userRepository.findById(id) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + } + } + throw new BusinessException(); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/service/FileService.java b/src/main/java/com/genius/gitget/global/file/service/FileService.java new file mode 100644 index 00000000..43ec8c29 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/service/FileService.java @@ -0,0 +1,70 @@ +package com.genius.gitget.global.file.service; + +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.file.dto.UpdateDTO; +import com.genius.gitget.global.util.exception.BusinessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional(readOnly = true) +public interface FileService { + + /** + * Files에 저장된 파일의 접근 URI 반환 + * + * @param files 얻기 원하는 파일의 정보를 담고 있는 Files 객체 + * @return + */ + String getFileAccessURI(Files files); + + /** + * 전달한 파일 저장 후, Files 객체 형성에 필요한 정보를 담은 객체 반환 + * + * @param multipartFile 저장하고자 전달한 파일 + * @param fileType 저장하고자하는 파일의 종류 (Topic, Instance, Profile 중 1) + * @return Files 객체 생성에 필요한 정보(UploadDTO) 반환 + */ + FileDTO upload(MultipartFile multipartFile, FileType fileType); + + /** + * 기존에 저장소에 저장되어 있던 파일을 특정 타입에 복사 후, Files 객체 생성에 필요한 정보들을 반환 + * NOTE!! 복사 이전에 원본이 되는 파일이 저장소에 존재하는지 `validateFileExist()`를 통해 확인 필요 + * + * @param files 복사하고자하는 파일의 정보를 담고 있는 Files 객체 + * @param fileType 복사해서 적용하고 싶은 대상의 파일 타입(TOPIC/INSTANCE/PROFILE 중 택 1) + * @return Files 객체 생성에 필요한 정보(UploadDTO) 반환 + * @throws BusinessException 원본이 되는 파일이 저장소에 존재하지 않는 경우 FILE_NOT_EXIST 발생 + */ + FileDTO copy(Files files, FileType fileType); + + /** + * Files에 해당하는 이미지를 찾아서 삭제 및 새로운 이미지 저장 후, Files 내용 갱신에 필요한 정보들을 반환 + * + * @param files 대체 하고자하는 대상 객체 + * @param multipartFile 저장하고자하는 파일 + * @return Files 내용 갱신에 필요한 정보(UpdateDTO) 반환 + */ + UpdateDTO update(Files files, MultipartFile multipartFile); + + /** + * Files 객체 내의 정보를 활용하여 저장소(Local/S3)에서 해당 파일 삭제. + * + * @param files 삭제하고자하는 Files 객체 + * @throws BusinessException 삭제에 실패했을 때 발생 + */ + void deleteInStorage(Files files); + + /** + * Files 객체 내의 정보를 활용하여 저장소에 파일이 저장이 되어 있는지 확인 후 boolean 반환 + * 각 저장소의 특성에 맞춰 Files 내의 메타데이터를 통해 저장소 내에 파일이 제대로 저장되어 있는지 확인 + * 파일이 존재하지 않는 경우 FILE_NOT_EXIST 예외 발생 시킬 것 + * + * @param files 저장소에 저장되어 있는지 확인하고자하는 Files 객체 + * @throws FILE_NOT_EXIST 저장소에서 파일(이미지)을 찾을 수 없을 때 발생 + */ + void validateFileExist(Files files); +} diff --git a/src/main/java/com/genius/gitget/global/file/service/FileUtil.java b/src/main/java/com/genius/gitget/global/file/service/FileUtil.java new file mode 100644 index 00000000..22e0a582 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/service/FileUtil.java @@ -0,0 +1,72 @@ +package com.genius.gitget.global.file.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.INVALID_FILE_NAME; +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_SUPPORTED_EXTENSION; + +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.CopyDTO; +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.util.exception.BusinessException; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class FileUtil { + private final List validExtensions = List.of("jpg", "jpeg", "png", "gif"); + + + public FileDTO getFileDTO(MultipartFile file, FileType fileType, final String UPLOAD_PATH) { + String originalFilename = file.getOriginalFilename(); + String savedFilename = getSavedFilename(originalFilename); + + return FileDTO.builder() + .fileType(fileType) + .originalFilename(originalFilename) + .savedFilename(savedFilename) + .fileURI(UPLOAD_PATH + fileType.getPath() + savedFilename) + .build(); + } + + public CopyDTO getCopyInfo(Files files, FileType fileType, final String UPLOAD_PATH) { + String originalFilename = files.getOriginalFilename(); + String savedFilename = getSavedFilename(originalFilename); + + return CopyDTO.builder() + .fileType(fileType) + .originalFilename(originalFilename) + .savedFilename(savedFilename) + .fileURI(UPLOAD_PATH + fileType.getPath() + savedFilename) + .folderURI(UPLOAD_PATH + fileType.getPath()) + .build(); + } + + public void validateFile(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + + if (originalFilename == null || Objects.equals(originalFilename, "")) { + throw new BusinessException(INVALID_FILE_NAME); + } + + String extension = extractExtension(originalFilename); + if (validExtensions.stream() + .noneMatch(ex -> ex.equals(extension))) { + throw new BusinessException(NOT_SUPPORTED_EXTENSION); + } + } + + public String getSavedFilename(String originalFilename) { + String uuid = UUID.randomUUID().toString(); + String extension = extractExtension(originalFilename); + + return uuid + "." + extension; + } + + private String extractExtension(String filename) { + int index = filename.lastIndexOf("."); + return filename.substring(index + 1).toLowerCase(); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/service/FilesManager.java b/src/main/java/com/genius/gitget/global/file/service/FilesManager.java new file mode 100644 index 00000000..2f2b5322 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/service/FilesManager.java @@ -0,0 +1,132 @@ +package com.genius.gitget.global.file.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_NOT_EXIST; +import static com.genius.gitget.global.util.exception.ErrorCode.MULTIPART_FILE_NOT_EXIST; + +import com.genius.gitget.global.file.domain.FileHolder; +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.dto.UpdateDTO; +import com.genius.gitget.global.file.repository.FilesRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FilesManager { + private final FileService fileService; + private final FilesRepository filesRepository; + + + @Transactional + public Files uploadFile(MultipartFile multipartFile, FileType fileType) { + FileDTO fileDTO = fileService.upload(multipartFile, fileType); + + Files file = Files.builder() + .originalFilename(fileDTO.originalFilename()) + .savedFilename(fileDTO.savedFilename()) + .fileType(fileDTO.fileType()) + .fileURI(fileDTO.fileURI()) + .build(); + return filesRepository.save(file); + } + + @Transactional + public Files uploadFile(FileHolder fileHolder, MultipartFile multipartFile, FileType fileType) { + if (multipartFile == null) { + throw new BusinessException(MULTIPART_FILE_NOT_EXIST); + } + FileDTO fileDTO = fileService.upload(multipartFile, fileType); + + Files file = Files.builder() + .originalFilename(fileDTO.originalFilename()) + .savedFilename(fileDTO.savedFilename()) + .fileType(fileDTO.fileType()) + .fileURI(fileDTO.fileURI()) + .build(); + fileHolder.setFiles(file); + return filesRepository.save(file); + } + + @Transactional + public Files copyFile(Files files, FileType fileType) { + FileDTO fileDTO = fileService.copy(files, fileType); + + Files copyFiles = Files.create(fileDTO); + return filesRepository.save(copyFiles); + } + + @Transactional + public Files updateFile(Long fileId, MultipartFile multipartFile) { + Files files = filesRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(FILE_NOT_EXIST)); + + if (multipartFile == null) { + return files; + } + + UpdateDTO updateDTO = fileService.update(files, multipartFile); + files.updateFiles(updateDTO); + return files; + } + + @Transactional + public Files updateFile(Optional optionalFiles, MultipartFile multipartFile) { + Files files = optionalFiles.orElseThrow(() -> new BusinessException(FILE_NOT_EXIST)); + if (multipartFile == null) { + return files; + } + + UpdateDTO updateDTO = fileService.update(files, multipartFile); + files.updateFiles(updateDTO); + return files; + } + + /** + * NOTE: 삭제하고자하는 Files 엔티티와 연관관계에 있는 엔티티에서 연관관계를 끊어줘야 합니다. + * + * @param fileId 삭제하고자하는 Files 엔티티의 PK + */ + @Transactional + public void deleteFile(Long fileId) { + Files files = filesRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(FILE_NOT_EXIST)); + + fileService.deleteInStorage(files); + filesRepository.delete(files); + } + + @Transactional + public void deleteFile(Optional optionalFiles) { + if (optionalFiles.isEmpty()) { + return; + } + Files files = optionalFiles.get(); + + fileService.deleteInStorage(files); + filesRepository.delete(files); + } + + public Files findById(Long fileId) { + return filesRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(FILE_NOT_EXIST)); + } + + public FileResponse convertToFileResponse(Optional optionalFiles) { + return optionalFiles + .map(files -> { + String fileAccessURI = fileService.getFileAccessURI(files); + return FileResponse.createExistFile(files.getId(), fileAccessURI); + }) + .orElseGet(FileResponse::createNotExistFile); + } +} diff --git a/src/main/java/com/genius/gitget/global/file/service/LocalFileService.java b/src/main/java/com/genius/gitget/global/file/service/LocalFileService.java new file mode 100644 index 00000000..56298525 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/service/LocalFileService.java @@ -0,0 +1,116 @@ +package com.genius.gitget.global.file.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_NOT_COPIED; +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_NOT_DELETED; +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_NOT_EXIST; +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_NOT_SAVED; + +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.CopyDTO; +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.file.dto.UpdateDTO; +import com.genius.gitget.global.util.exception.BusinessException; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardCopyOption; +import java.util.Base64; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.UrlResource; +import org.springframework.web.multipart.MultipartFile; + +public class LocalFileService implements FileService { + private final String UPLOAD_PATH; + private final FileUtil fileUtil; + + + public LocalFileService(FileUtil fileUtil, @Value("${file.upload.path}") String UPLOAD_PATH) { + this.fileUtil = fileUtil; + this.UPLOAD_PATH = UPLOAD_PATH; + } + + @Override + public FileDTO upload(MultipartFile multipartFile, FileType fileType) { + fileUtil.validateFile(multipartFile); + FileDTO fileDTO = fileUtil.getFileDTO(multipartFile, fileType, UPLOAD_PATH); + + try { + File file = new File(fileDTO.fileURI()); + createPath(fileDTO.fileURI()); + multipartFile.transferTo(file); + } catch (IOException e) { + throw new BusinessException(FILE_NOT_SAVED); + } + + return fileDTO; + } + + @Override + public String getFileAccessURI(Files files) { + try { + UrlResource urlResource = new UrlResource("file:" + files.getFileURI()); + byte[] encode = Base64.getEncoder().encode(urlResource.getContentAsByteArray()); + return new String(encode, StandardCharsets.UTF_8); + } catch (IOException e) { + return ""; + } + } + + @Override + public FileDTO copy(Files files, FileType fileType) { + validateFileExist(files); + + CopyDTO copyDTO = fileUtil.getCopyInfo(files, fileType, UPLOAD_PATH); + createPath(copyDTO.folderURI()); + + File originFile = new File(files.getFileURI()); + File copyFile = new File(copyDTO.fileURI()); + + try { + java.nio.file.Files.copy(originFile.toPath(), copyFile.toPath(), + StandardCopyOption.COPY_ATTRIBUTES); + } catch (IOException e) { + throw new BusinessException(FILE_NOT_COPIED); + } + return FileDTO.builder() + .fileType(fileType) + .originalFilename(copyDTO.originalFilename()) + .savedFilename(copyDTO.savedFilename()) + .fileURI(copyDTO.fileURI()) + .build(); + } + + @Override + public UpdateDTO update(Files files, MultipartFile multipartFile) { + deleteInStorage(files); + FileDTO fileDTO = upload(multipartFile, files.getFileType()); + + return UpdateDTO.of(fileDTO); + } + + @Override + public void deleteInStorage(Files files) { + String fileURI = files.getFileURI(); + File targetFile = new File(fileURI); + if (!targetFile.delete()) { + throw new BusinessException(FILE_NOT_DELETED); + } + } + + @Override + public void validateFileExist(Files files) { + String fileURI = files.getFileURI(); + File file = new File(fileURI); + if (!file.exists()) { + throw new BusinessException(FILE_NOT_EXIST); + } + } + + private void createPath(String uri) { + File file = new File(uri); + if (!file.exists()) { + file.mkdirs(); + } + } +} diff --git a/src/main/java/com/genius/gitget/global/file/service/S3FileService.java b/src/main/java/com/genius/gitget/global/file/service/S3FileService.java new file mode 100644 index 00000000..44ad4201 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/file/service/S3FileService.java @@ -0,0 +1,95 @@ +package com.genius.gitget.global.file.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_NOT_EXIST; + +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CopyObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.CopyDTO; +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.file.dto.UpdateDTO; +import com.genius.gitget.global.util.exception.BusinessException; +import java.io.IOException; +import org.springframework.web.multipart.MultipartFile; + +public class S3FileService implements FileService { + private final AmazonS3 amazonS3; + private final FileUtil fileUtil; + private final String bucket; + private final String cloudFrontDomain; + + public S3FileService(AmazonS3 amazonS3, FileUtil fileUtil, String bucket, String cloudFrontDomain) { + this.amazonS3 = amazonS3; + this.fileUtil = fileUtil; + this.bucket = bucket; + this.cloudFrontDomain = cloudFrontDomain; + } + + @Override + public String getFileAccessURI(Files files) { + return cloudFrontDomain + files.getFileURI(); + } + + @Override + public FileDTO upload(MultipartFile multipartFile, FileType fileType) { + try { + fileUtil.validateFile(multipartFile); + FileDTO fileDTO = fileUtil.getFileDTO(multipartFile, fileType, ""); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(multipartFile.getSize()); + objectMetadata.setContentType(multipartFile.getContentType()); + + amazonS3.putObject(bucket, fileDTO.fileURI(), multipartFile.getInputStream(), objectMetadata); + return fileDTO; + } catch (IOException e) { + throw new BusinessException(e); + } + } + + @Override + public FileDTO copy(Files files, FileType fileType) { + validateFileExist(files); + + CopyDTO copyDTO = fileUtil.getCopyInfo(files, fileType, ""); + + CopyObjectRequest copyObjectRequest = new CopyObjectRequest( + bucket, files.getFileURI(), + bucket, copyDTO.fileURI() + ); + amazonS3.copyObject(copyObjectRequest); + return FileDTO.builder() + .fileType(fileType) + .originalFilename(copyDTO.originalFilename()) + .savedFilename(copyDTO.savedFilename()) + .fileURI(copyDTO.fileURI()) + .build(); + } + + @Override + public UpdateDTO update(Files files, MultipartFile multipartFile) { + deleteInStorage(files); + FileDTO fileDTO = upload(multipartFile, files.getFileType()); + + return UpdateDTO.of(fileDTO); + } + + @Override + public void deleteInStorage(Files files) { + try { + amazonS3.deleteObject(bucket, files.getFileURI()); + } catch (SdkClientException e) { + throw new BusinessException(e); + } + } + + @Override + public void validateFileExist(Files files) { + if (!amazonS3.doesObjectExist(bucket, files.getFileURI())) { + throw new BusinessException(FILE_NOT_EXIST); + } + } +} diff --git a/src/main/java/com/genius/gitget/global/page/CustomPageImpl.java b/src/main/java/com/genius/gitget/global/page/CustomPageImpl.java new file mode 100644 index 00000000..c69ea0b3 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/page/CustomPageImpl.java @@ -0,0 +1,42 @@ +package com.genius.gitget.global.page; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CustomPageImpl extends PageImpl { + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public CustomPageImpl(@JsonProperty("content") List content, @JsonProperty("number") int number, + @JsonProperty("size") int size, + @JsonProperty("totalElements") Long totalElements, @JsonProperty("pageable") JsonNode pageable, + @JsonProperty("last") boolean last, + @JsonProperty("totalPages") int totalPages, @JsonProperty("sort") JsonNode sort, + @JsonProperty("first") boolean first, + @JsonProperty("numberOfElements") int numberOfElements) { + super(content, PageRequest.of(number, size), totalElements); + } + + public CustomPageImpl(List content, Pageable pageable, Long total) { + super(content, pageable, total); + } + + public CustomPageImpl(List content) { + super(content); + } + + public CustomPageImpl() { + super(new ArrayList()); + } +} + diff --git a/src/main/java/com/genius/gitget/global/page/LimitedSizePagination.java b/src/main/java/com/genius/gitget/global/page/LimitedSizePagination.java new file mode 100644 index 00000000..486dde52 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/page/LimitedSizePagination.java @@ -0,0 +1,18 @@ +package com.genius.gitget.global.page; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.constraints.Positive; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface LimitedSizePagination { + @Positive + int maxSize() default 200; +} + diff --git a/src/main/java/com/genius/gitget/global/security/config/CustomCorsConfigurationSource.java b/src/main/java/com/genius/gitget/global/security/config/CustomCorsConfigurationSource.java new file mode 100644 index 00000000..3c7d475f --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/config/CustomCorsConfigurationSource.java @@ -0,0 +1,37 @@ +package com.genius.gitget.global.security.config; + +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_HEADER; +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_REISSUED_HEADER; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +@Component +public class CustomCorsConfigurationSource implements CorsConfigurationSource { + private final String ALLOWED_ORIGIN; + private final List ALLOWED_METHODS = List.of("POST", "GET", "PATCH", "OPTIONS", "DELETE"); + + public CustomCorsConfigurationSource(@Value("${url.base}") String BASE_URL) { + ALLOWED_ORIGIN = BASE_URL; + } + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(Collections.singletonList(ALLOWED_ORIGIN)); + config.setAllowedMethods(ALLOWED_METHODS); + config.setAllowCredentials(true); + config.setAllowedHeaders(Collections.singletonList("*")); + + config.setExposedHeaders(Collections.singletonList(ACCESS_HEADER.getValue())); + config.addExposedHeader(ACCESS_REISSUED_HEADER.getValue()); + + config.setMaxAge(3600L); + return config; + } +} diff --git a/src/main/java/com/genius/gitget/global/security/config/SecurityConfig.java b/src/main/java/com/genius/gitget/global/security/config/SecurityConfig.java new file mode 100644 index 00000000..80a5eae7 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/config/SecurityConfig.java @@ -0,0 +1,73 @@ +package com.genius.gitget.global.security.config; + + +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.security.filter.ExceptionHandlerFilter; +import com.genius.gitget.global.security.filter.JwtAuthenticationFilter; +import com.genius.gitget.global.security.handler.OAuth2FailureHandler; +import com.genius.gitget.global.security.handler.OAuth2SuccessHandler; +import com.genius.gitget.global.security.service.CustomOAuth2UserService; +import com.genius.gitget.global.security.service.JwtFacade; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsUtils; + +@Configuration +@Order(1) +@RequiredArgsConstructor +@EnableWebSecurity +public class SecurityConfig { + public static final String PERMITTED_URI[] = {"/v3/**", "/swagger-ui/**", "/api/auth/**", "/login", + "/favicon.ico"}; + private static final String PERMITTED_ROLES[] = {"USER", "ADMIN"}; + private final CustomCorsConfigurationSource customCorsConfigurationSource; + private final CustomOAuth2UserService customOAuthService; + private final JwtFacade jwtFacade; + private final UserService userService; + private final OAuth2SuccessHandler successHandler; + private final OAuth2FailureHandler failureHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http.cors(corsCustomizer -> corsCustomizer + .configurationSource(customCorsConfigurationSource) + ) + .csrf(CsrfConfigurer::disable) + .httpBasic(HttpBasicConfigurer::disable) + .formLogin(FormLoginConfigurer::disable) + .authorizeHttpRequests(request -> request + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() + .requestMatchers(PERMITTED_URI).permitAll() + .anyRequest().hasAnyRole(PERMITTED_ROLES)) + + // JWT 사용으로 인한 세션 미사용 + .sessionManagement(configurer -> configurer + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // JWT 검증 필터 추가 + .addFilterBefore(new JwtAuthenticationFilter(jwtFacade, userService), + UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class) + + // OAuth 로그인 설정 + .oauth2Login(customConfigurer -> customConfigurer + .successHandler(successHandler) + .failureHandler(failureHandler) + .userInfoEndpoint(endpointConfig -> endpointConfig.userService(customOAuthService)) + ); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/security/constants/JwtRule.java b/src/main/java/com/genius/gitget/global/security/constants/JwtRule.java new file mode 100644 index 00000000..0492a016 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/constants/JwtRule.java @@ -0,0 +1,20 @@ +package com.genius.gitget.global.security.constants; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum JwtRule { + + ACCESS_HEADER("Authorization"), + ACCESS_PREFIX("Bearer "), + ACCESS_REISSUED_HEADER("token-reissued"), + + REFRESH_PREFIX("refresh"), + + REFRESH_ISSUE("Set-Cookie"), + REFRESH_RESOLVE("Cookie"); + + private final String value; +} diff --git a/src/main/java/com/genius/gitget/global/security/constants/ProviderInfo.java b/src/main/java/com/genius/gitget/global/security/constants/ProviderInfo.java new file mode 100644 index 00000000..50a1d60e --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/constants/ProviderInfo.java @@ -0,0 +1,27 @@ +package com.genius.gitget.global.security.constants; + +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum ProviderInfo { + GITHUB(null, "id", "login"), + KAKAO("kakao_account", "id", "email"), + NAVER("response", "id", "email"), + GOOGLE(null, "sub", "email"); + + private final String attributeKey; + private final String providerCode; + private final String identifier; + + public static ProviderInfo from(String provider) { + String upperCastedProvider = provider.toUpperCase(); + + return Arrays.stream(ProviderInfo.values()) + .filter(item -> item.name().equals(upperCastedProvider)) + .findFirst() + .orElseThrow(); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/constants/ProviderType.java b/src/main/java/com/genius/gitget/global/security/constants/ProviderType.java new file mode 100644 index 00000000..81de6425 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/constants/ProviderType.java @@ -0,0 +1,18 @@ +package com.genius.gitget.global.security.constants; + +import java.util.Arrays; + +public enum ProviderType { + KAKAO, + NAVER, + GOOGLE; + + public static ProviderType from(String provider) { + String upperCastedProvider = provider.toUpperCase(); + + return Arrays.stream(ProviderType.values()) + .filter(item -> item.name().equals(upperCastedProvider)) + .findFirst() + .orElseThrow(); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/constants/TokenStatus.java b/src/main/java/com/genius/gitget/global/security/constants/TokenStatus.java new file mode 100644 index 00000000..7da9d3dd --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/constants/TokenStatus.java @@ -0,0 +1,12 @@ +package com.genius.gitget.global.security.constants; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum TokenStatus { + AUTHENTICATED, + EXPIRED, + INVALID +} diff --git a/src/main/java/com/genius/gitget/global/security/controller/AuthController.java b/src/main/java/com/genius/gitget/global/security/controller/AuthController.java new file mode 100644 index 00000000..1f400a31 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/controller/AuthController.java @@ -0,0 +1,80 @@ +package com.genius.gitget.global.security.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.dto.LoginRequest; +import com.genius.gitget.challenge.user.facade.UserFacade; +import com.genius.gitget.global.security.dto.AuthResponse; +import com.genius.gitget.global.security.dto.GuestResponse; +import com.genius.gitget.global.security.dto.TokenRequest; +import com.genius.gitget.global.security.service.JwtFacade; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class AuthController { + private final UserFacade userFacade; + private final JwtFacade jwtFacade; + + @PostMapping("/auth") + public ResponseEntity> generateToken(HttpServletResponse response, + @RequestBody TokenRequest tokenRequest) { + + User authUser = userFacade.getAuthUser(tokenRequest.identifier()); + + jwtFacade.generateAccessToken(response, authUser); + jwtFacade.generateRefreshToken(response, authUser); + jwtFacade.setReissuedHeader(response); + + AuthResponse authResponse = userFacade.getUserAuthInfo(authUser.getIdentifier()); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), authResponse) + ); + } + + @PostMapping("/logout") + public ResponseEntity logout(@GitGetUser User user, HttpServletResponse response) { + jwtFacade.logout(response, user.getIdentifier()); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } + + @GetMapping("/auth/health-check") + public String healthCheck() { + return "health-check-ok"; + } + + @PostMapping("/auth/guest") + public ResponseEntity> loginWithGuest(HttpServletResponse response, + @RequestBody LoginRequest loginRequest) { + User authUser = userFacade.getGuestUser(loginRequest); + + jwtFacade.generateAccessToken(response, authUser); + jwtFacade.generateRefreshToken(response, authUser); + jwtFacade.setReissuedHeader(response); + + AuthResponse authResponse = userFacade.getUserAuthInfo(authUser.getIdentifier()); + GuestResponse guestResponse = GuestResponse.from(authResponse, authUser.getIdentifier()); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), guestResponse) + ); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/domain/CustomCsrfToken.java b/src/main/java/com/genius/gitget/global/security/domain/CustomCsrfToken.java new file mode 100644 index 00000000..0d4870bd --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/domain/CustomCsrfToken.java @@ -0,0 +1,20 @@ +package com.genius.gitget.global.security.domain; + +import org.springframework.security.web.csrf.CsrfToken; + +public class CustomCsrfToken implements CsrfToken { + @Override + public String getHeaderName() { + return null; + } + + @Override + public String getParameterName() { + return null; + } + + @Override + public String getToken() { + return null; + } +} diff --git a/src/main/java/com/genius/gitget/global/security/domain/Token.java b/src/main/java/com/genius/gitget/global/security/domain/Token.java new file mode 100644 index 00000000..8d9584fe --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/domain/Token.java @@ -0,0 +1,23 @@ +package com.genius.gitget.global.security.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Getter +@Document(collection = "Token") +@NoArgsConstructor +public class Token { + @Id + private String identifier; + + private String token; + + @Builder + public Token(String identifier, String token) { + this.identifier = identifier; + this.token = token; + } +} diff --git a/src/main/java/com/genius/gitget/global/security/domain/UserPrincipal.java b/src/main/java/com/genius/gitget/global/security/domain/UserPrincipal.java new file mode 100644 index 00000000..6ec0f43c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/domain/UserPrincipal.java @@ -0,0 +1,73 @@ +package com.genius.gitget.global.security.domain; + +import com.genius.gitget.challenge.user.domain.User; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +public class UserPrincipal implements UserDetails, OAuth2User { + + private User user; + private String nameAttributeKey; + private Map attributes; + private Collection authorities; + + public UserPrincipal(User user) { + this.user = user; + this.authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey())); + } + + public UserPrincipal(User user, Map attributes, String nameAttributeKey) { + this.user = user; + this.authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey())); + this.attributes = attributes; + this.nameAttributeKey = nameAttributeKey; + } + + /** + * OAuth2User method implements + */ + @Override + public String getName() { + return user.getIdentifier(); + } + + /** + * UserDetails method implements + */ + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(user.getId()); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/genius/gitget/global/security/dto/AuthResponse.java b/src/main/java/com/genius/gitget/global/security/dto/AuthResponse.java new file mode 100644 index 00000000..dd5a3e7c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/dto/AuthResponse.java @@ -0,0 +1,9 @@ +package com.genius.gitget.global.security.dto; + +import com.genius.gitget.challenge.user.domain.Role; + +public record AuthResponse( + Role role, + Integer frameId +) { +} diff --git a/src/main/java/com/genius/gitget/global/security/dto/GuestResponse.java b/src/main/java/com/genius/gitget/global/security/dto/GuestResponse.java new file mode 100644 index 00000000..a4403e2c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/dto/GuestResponse.java @@ -0,0 +1,15 @@ +package com.genius.gitget.global.security.dto; + +import com.genius.gitget.challenge.user.domain.Role; +import jakarta.validation.constraints.NotNull; + +public record GuestResponse( + String identifier, + Role role, + Integer frameId +) { + + public static GuestResponse from(@NotNull AuthResponse authResponse, @NotNull String identifier) { + return new GuestResponse(identifier, authResponse.role(), authResponse.frameId()); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/dto/SignupResponse.java b/src/main/java/com/genius/gitget/global/security/dto/SignupResponse.java new file mode 100644 index 00000000..8a7db7f5 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/dto/SignupResponse.java @@ -0,0 +1,11 @@ +package com.genius.gitget.global.security.dto; + +public record SignupResponse( + Long userId, + String identifier +) { + + public static SignupResponse of(Long userId, String identifier) { + return new SignupResponse(userId, identifier); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/dto/TokenRequest.java b/src/main/java/com/genius/gitget/global/security/dto/TokenRequest.java new file mode 100644 index 00000000..d3a0f485 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/dto/TokenRequest.java @@ -0,0 +1,6 @@ +package com.genius.gitget.global.security.dto; + +public record TokenRequest( + String identifier +) { +} diff --git a/src/main/java/com/genius/gitget/global/security/filter/ExceptionHandlerFilter.java b/src/main/java/com/genius/gitget/global/security/filter/ExceptionHandlerFilter.java new file mode 100644 index 00000000..4e648f7c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/filter/ExceptionHandlerFilter.java @@ -0,0 +1,40 @@ +package com.genius.gitget.global.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (BusinessException e) { + setErrorResponse(response, e); + } + } + + private void setErrorResponse(HttpServletResponse response, BusinessException e) throws IOException { + CommonResponse commonResponse = new CommonResponse(e.getStatus(), e.getStatus().value(), e.getMessage()); + ObjectMapper objectMapper = new ObjectMapper(); + + response.setStatus(e.getStatus().value()); + response.setContentType("application/json; charset=UTF-8"); + + response.getWriter().write( + objectMapper.writeValueAsString(commonResponse) + ); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/genius/gitget/global/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..65492803 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,74 @@ +package com.genius.gitget.global.security.filter; + +import static com.genius.gitget.global.security.config.SecurityConfig.PERMITTED_URI; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.security.service.JwtFacade; +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.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtFacade jwtFacade; + private final UserService userService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (isPermittedURI(request.getRequestURI())) { + SecurityContextHolder.getContext().setAuthentication(null); + filterChain.doFilter(request, response); + return; + } + + String accessToken = jwtFacade.resolveAccessToken(request); + if (jwtFacade.validateAccessToken(accessToken)) { + setAuthenticationToContext(accessToken); + filterChain.doFilter(request, response); + return; + } + + String refreshToken = jwtFacade.resolveRefreshToken(request); + User user = findUserByRefreshToken(refreshToken); + + if (jwtFacade.validateRefreshToken(refreshToken, user.getIdentifier())) { + String reissuedAccessToken = jwtFacade.generateAccessToken(response, user); + jwtFacade.generateRefreshToken(response, user); + jwtFacade.setReissuedHeader(response); + + setAuthenticationToContext(reissuedAccessToken); + filterChain.doFilter(request, response); + return; + } + + jwtFacade.logout(response, user.getIdentifier()); + } + + private boolean isPermittedURI(String requestURI) { + return Arrays.stream(PERMITTED_URI) + .anyMatch(permitted -> { + String replace = permitted.replace("*", ""); + return requestURI.contains(replace) || replace.contains(requestURI); + }); + } + + private User findUserByRefreshToken(String refreshToken) { + String identifier = jwtFacade.getIdentifierFromRefresh(refreshToken); + return userService.findByIdentifier(identifier); + } + + private void setAuthenticationToContext(String accessToken) { + Authentication authentication = jwtFacade.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/handler/OAuth2FailureHandler.java b/src/main/java/com/genius/gitget/global/security/handler/OAuth2FailureHandler.java new file mode 100644 index 00000000..7962a1a2 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/handler/OAuth2FailureHandler.java @@ -0,0 +1,32 @@ +package com.genius.gitget.global.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + private final String REDIRECT_URL; + private final String ERROR_PARAM_PREFIX = "error"; + + public OAuth2FailureHandler(@Value("${url.base}") String REDIRECT_URL) { + this.REDIRECT_URL = REDIRECT_URL; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + String redirectUrl = UriComponentsBuilder.fromUriString(REDIRECT_URL) + .queryParam(ERROR_PARAM_PREFIX, exception.getLocalizedMessage()) + .build() + .toUriString(); + + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/handler/OAuth2SuccessHandler.java b/src/main/java/com/genius/gitget/global/security/handler/OAuth2SuccessHandler.java new file mode 100644 index 00000000..8fd8000b --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/handler/OAuth2SuccessHandler.java @@ -0,0 +1,60 @@ +package com.genius.gitget.global.security.handler; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final String SIGNUP_URL; + private final String AUTH_URL; + private final UserRepository userRepository; + + public OAuth2SuccessHandler(@Value("${url.base}") String BASE_URL, + @Value("${url.path.signup}") String SIGN_UP_PATH, + @Value("${url.path.auth}") String AUTH_PATH, + UserRepository userRepository) { + this.userRepository = userRepository; + this.SIGNUP_URL = BASE_URL + SIGN_UP_PATH; + this.AUTH_URL = BASE_URL + AUTH_PATH; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String identifier = oAuth2User.getName(); + + User user = userRepository.findByIdentifier(identifier) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + String redirectUrl = getRedirectUrlByRole(user.getRole(), identifier); + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } + + private String getRedirectUrlByRole(Role role, String identifier) { + if (role == Role.NOT_REGISTERED) { + return UriComponentsBuilder.fromUriString(SIGNUP_URL) + .queryParam("identifier", identifier) + .build() + .toUriString(); + } + + return UriComponentsBuilder.fromHttpUrl(AUTH_URL) + .queryParam("identifier", identifier) + .build() + .toUriString(); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/info/OAuth2UserInfo.java b/src/main/java/com/genius/gitget/global/security/info/OAuth2UserInfo.java new file mode 100644 index 00000000..73fec3f2 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/info/OAuth2UserInfo.java @@ -0,0 +1,16 @@ +package com.genius.gitget.global.security.info; + +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public abstract class OAuth2UserInfo { + + protected Map attributes; + + public abstract String getProviderCode(); + + public abstract String getUserIdentifier(); +} diff --git a/src/main/java/com/genius/gitget/global/security/info/OAuth2UserInfoFactory.java b/src/main/java/com/genius/gitget/global/security/info/OAuth2UserInfoFactory.java new file mode 100644 index 00000000..fcf3b056 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/info/OAuth2UserInfoFactory.java @@ -0,0 +1,29 @@ +package com.genius.gitget.global.security.info; + +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.info.impl.GithubOAuth2UserInfo; +import com.genius.gitget.global.security.info.impl.GoogleOAuth2UserInfo; +import com.genius.gitget.global.security.info.impl.KakaoOAuth2UserInfo; +import com.genius.gitget.global.security.info.impl.NaverOAuth2UserInfo; +import java.util.Map; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; + +public class OAuth2UserInfoFactory { + public static OAuth2UserInfo getOAuth2UserInfo(ProviderInfo providerInfo, Map attributes) { + switch (providerInfo) { + case GITHUB -> { + return new GithubOAuth2UserInfo(attributes); + } + case KAKAO -> { + return new KakaoOAuth2UserInfo(attributes); + } + case NAVER -> { + return new NaverOAuth2UserInfo(attributes); + } + case GOOGLE -> { + return new GoogleOAuth2UserInfo(attributes); + } + } + throw new OAuth2AuthenticationException("INVALID PROVIDER TYPE"); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/info/impl/GithubOAuth2UserInfo.java b/src/main/java/com/genius/gitget/global/security/info/impl/GithubOAuth2UserInfo.java new file mode 100644 index 00000000..0cafb08b --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/info/impl/GithubOAuth2UserInfo.java @@ -0,0 +1,22 @@ +package com.genius.gitget.global.security.info.impl; + +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.info.OAuth2UserInfo; +import java.util.Map; + +public class GithubOAuth2UserInfo extends OAuth2UserInfo { + + public GithubOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getProviderCode() { + return (String) attributes.get(ProviderInfo.GITHUB.getProviderCode()); + } + + @Override + public String getUserIdentifier() { + return (String) attributes.get(ProviderInfo.GITHUB.getIdentifier()); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/info/impl/GoogleOAuth2UserInfo.java b/src/main/java/com/genius/gitget/global/security/info/impl/GoogleOAuth2UserInfo.java new file mode 100644 index 00000000..a830c6fc --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/info/impl/GoogleOAuth2UserInfo.java @@ -0,0 +1,23 @@ +package com.genius.gitget.global.security.info.impl; + + +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.info.OAuth2UserInfo; +import java.util.Map; + +public class GoogleOAuth2UserInfo extends OAuth2UserInfo { + + public GoogleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getProviderCode() { + return (String) attributes.get(ProviderInfo.GOOGLE.getProviderCode()); + } + + @Override + public String getUserIdentifier() { + return (String) attributes.get(ProviderInfo.GOOGLE.getIdentifier()); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/info/impl/KakaoOAuth2UserInfo.java b/src/main/java/com/genius/gitget/global/security/info/impl/KakaoOAuth2UserInfo.java new file mode 100644 index 00000000..c25f155c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/info/impl/KakaoOAuth2UserInfo.java @@ -0,0 +1,24 @@ +package com.genius.gitget.global.security.info.impl; + +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.info.OAuth2UserInfo; +import java.util.Map; + +public class KakaoOAuth2UserInfo extends OAuth2UserInfo { + private String providerId; + + public KakaoOAuth2UserInfo(Map attributes) { + super((Map) attributes.get(ProviderInfo.KAKAO.getAttributeKey())); + this.providerId = String.valueOf(attributes.get(ProviderInfo.KAKAO.getIdentifier())); + } + + @Override + public String getProviderCode() { + return providerId; + } + + @Override + public String getUserIdentifier() { + return (String) attributes.get(ProviderInfo.KAKAO.getProviderCode()); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/info/impl/NaverOAuth2UserInfo.java b/src/main/java/com/genius/gitget/global/security/info/impl/NaverOAuth2UserInfo.java new file mode 100644 index 00000000..8e70f062 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/info/impl/NaverOAuth2UserInfo.java @@ -0,0 +1,23 @@ +package com.genius.gitget.global.security.info.impl; + + +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.info.OAuth2UserInfo; +import java.util.Map; + +public class NaverOAuth2UserInfo extends OAuth2UserInfo { + + public NaverOAuth2UserInfo(Map attributes) { + super((Map) attributes.get(ProviderInfo.NAVER.getAttributeKey())); + } + + @Override + public String getProviderCode() { + return (String) attributes.get(ProviderInfo.NAVER.getProviderCode()); + } + + @Override + public String getUserIdentifier() { + return (String) attributes.get(ProviderInfo.NAVER.getIdentifier()); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/repository/TokenRepository.java b/src/main/java/com/genius/gitget/global/security/repository/TokenRepository.java new file mode 100644 index 00000000..b0064c77 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/repository/TokenRepository.java @@ -0,0 +1,10 @@ +package com.genius.gitget.global.security.repository; + +import com.genius.gitget.global.security.domain.Token; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; +import org.springframework.stereotype.Repository; + +public interface TokenRepository extends MongoRepository { + Token findByIdentifier(String identifier); +} diff --git a/src/main/java/com/genius/gitget/global/security/service/CustomOAuth2UserService.java b/src/main/java/com/genius/gitget/global/security/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..4a4ec055 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/CustomOAuth2UserService.java @@ -0,0 +1,67 @@ +package com.genius.gitget.global.security.service; + +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.domain.UserPrincipal; +import com.genius.gitget.global.security.info.OAuth2UserInfo; +import com.genius.gitget.global.security.info.OAuth2UserInfoFactory; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import jakarta.transaction.Transactional; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService implements OAuth2UserService { + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + // OAuth2 로그인 진행 시 키가 되는 필드값. Primary Key와 같은 의미. + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint() + .getUserNameAttributeName(); + + // 서비스를 구분하는 코드 ex) Github, Naver + String providerCode = userRequest.getClientRegistration().getRegistrationId(); + + ProviderInfo providerInfo = ProviderInfo.from(providerCode); + Map attributes = oAuth2User.getAttributes(); + + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerInfo, attributes); + String userIdentifier = oAuth2UserInfo.getUserIdentifier(); + + User user = getUser(userIdentifier, providerInfo); + + return new UserPrincipal(user, attributes, userNameAttributeName); + } + + private User getUser(String userIdentifier, ProviderInfo providerInfo) { + Optional optionalUser = userRepository.findByOAuthInfo(userIdentifier, providerInfo); + + if (optionalUser.isEmpty()) { + User unregisteredUser = User.builder() + .identifier(userIdentifier) + .role(Role.NOT_REGISTERED) + .providerInfo(providerInfo) + .build(); + return userRepository.save(unregisteredUser); + } + return optionalUser.get(); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/service/CustomUserDetailsService.java b/src/main/java/com/genius/gitget/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..782d2699 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.genius.gitget.global.security.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.MEMBER_NOT_FOUND; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.domain.UserPrincipal; +import com.genius.gitget.global.util.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) { + User user = userRepository.findById(Long.valueOf(username)) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + + return new UserPrincipal(user); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/service/JwtFacade.java b/src/main/java/com/genius/gitget/global/security/service/JwtFacade.java new file mode 100644 index 00000000..b1e64bb3 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/JwtFacade.java @@ -0,0 +1,28 @@ +package com.genius.gitget.global.security.service; + +import com.genius.gitget.challenge.user.domain.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; + +public interface JwtFacade { + String generateAccessToken(HttpServletResponse response, User user); + + String generateRefreshToken(HttpServletResponse response, User user); + + String resolveAccessToken(HttpServletRequest request); + + String resolveRefreshToken(HttpServletRequest request); + + String getIdentifierFromRefresh(String refreshToken); + + boolean validateAccessToken(String accessToken); + + boolean validateRefreshToken(String refreshToken, String identifier); + + void setReissuedHeader(HttpServletResponse response); + + void logout(HttpServletResponse response, String identifier); + + Authentication getAuthentication(String accessToken); +} diff --git a/src/main/java/com/genius/gitget/global/security/service/JwtFacadeService.java b/src/main/java/com/genius/gitget/global/security/service/JwtFacadeService.java new file mode 100644 index 00000000..17c761b4 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/JwtFacadeService.java @@ -0,0 +1,165 @@ +package com.genius.gitget.global.security.service; + +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_HEADER; +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_PREFIX; +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_REISSUED_HEADER; +import static com.genius.gitget.global.security.constants.JwtRule.REFRESH_ISSUE; +import static com.genius.gitget.global.security.constants.JwtRule.REFRESH_PREFIX; +import static com.genius.gitget.global.util.exception.ErrorCode.JWT_NOT_FOUND_IN_COOKIE; +import static com.genius.gitget.global.util.exception.ErrorCode.JWT_NOT_FOUND_IN_HEADER; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.security.constants.TokenStatus; +import com.genius.gitget.global.security.domain.Token; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.security.Key; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@Slf4j +public class JwtFacadeService implements JwtFacade { + private final CustomUserDetailsService customUserDetailsService; + private final TokenService tokenService; + private final JwtGenerator jwtGenerator; + private final JwtUtil jwtUtil; + + private final Key ACCESS_SECRET_KEY; + private final Key REFRESH_SECRET_KEY; + private final long ACCESS_EXPIRATION; + private final long REFRESH_EXPIRATION; + + public JwtFacadeService(CustomUserDetailsService customUserDetailsService, + TokenService tokenService, + JwtGenerator jwtGenerator, JwtUtil jwtUtil, + @Value("${jwt.access-secret}") String ACCESS_SECRET_KEY, + @Value("${jwt.refresh-secret}") String REFRESH_SECRET_KEY, + @Value("${jwt.access-expiration}") long ACCESS_EXPIRATION, + @Value("${jwt.refresh-expiration}") long REFRESH_EXPIRATION) { + this.customUserDetailsService = customUserDetailsService; + this.tokenService = tokenService; + this.jwtGenerator = jwtGenerator; + this.jwtUtil = jwtUtil; + this.ACCESS_SECRET_KEY = jwtUtil.getSigningKey(ACCESS_SECRET_KEY); + this.REFRESH_SECRET_KEY = jwtUtil.getSigningKey(REFRESH_SECRET_KEY); + this.ACCESS_EXPIRATION = ACCESS_EXPIRATION; + this.REFRESH_EXPIRATION = REFRESH_EXPIRATION; + } + + + @Override + public String generateAccessToken(HttpServletResponse response, User requestUser) { + String accessToken = jwtGenerator.generateAccessToken(ACCESS_SECRET_KEY, ACCESS_EXPIRATION, requestUser); + String bearer = ACCESS_PREFIX.getValue() + accessToken; + response.setHeader(ACCESS_HEADER.getValue(), bearer); + response.setHeader(ACCESS_REISSUED_HEADER.getValue(), "False"); + + return accessToken; + } + + @Override + @Transactional + public String generateRefreshToken(HttpServletResponse response, User requestUser) { + String refreshToken = jwtGenerator.generateRefreshToken(REFRESH_SECRET_KEY, REFRESH_EXPIRATION, requestUser); + ResponseCookie cookie = setTokenToCookie(REFRESH_PREFIX.getValue(), refreshToken, REFRESH_EXPIRATION / 1000); + response.addHeader(REFRESH_ISSUE.getValue(), cookie.toString()); + + tokenService.save(new Token(requestUser.getIdentifier(), refreshToken)); + return refreshToken; + } + + private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeSeconds) { + return ResponseCookie.from(tokenPrefix, token) + .path("/") + .maxAge(maxAgeSeconds) + .httpOnly(true) + .sameSite("Strict") + .secure(true) + .build(); + } + + @Override + public boolean validateAccessToken(String token) { + return jwtUtil.getTokenStatus(token, ACCESS_SECRET_KEY) == TokenStatus.AUTHENTICATED; + } + + @Override + public boolean validateRefreshToken(String token, String identifier) { + boolean isRefreshValid = jwtUtil.getTokenStatus(token, REFRESH_SECRET_KEY) == TokenStatus.AUTHENTICATED; + boolean isHijacked = tokenService.isRefreshHijacked(identifier, token); + + return isRefreshValid && !isHijacked; + } + + @Override + public void setReissuedHeader(HttpServletResponse response) { + response.setHeader(ACCESS_REISSUED_HEADER.getValue(), "True"); + } + + @Override + public String resolveAccessToken(HttpServletRequest request) { + String bearerHeader = request.getHeader(ACCESS_HEADER.getValue()); + if (bearerHeader == null || bearerHeader.isEmpty()) { + throw new BusinessException(JWT_NOT_FOUND_IN_HEADER); + } + return bearerHeader.trim().substring(7); + } + + @Override + public String resolveRefreshToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + throw new BusinessException(JWT_NOT_FOUND_IN_COOKIE); + } + return jwtUtil.resolveTokenFromCookie(cookies, REFRESH_PREFIX); + } + + @Override + public String getIdentifierFromRefresh(String refreshToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(REFRESH_SECRET_KEY) + .build() + .parseClaimsJws(refreshToken) + .getBody() + .getSubject(); + } catch (Exception e) { + throw new BusinessException(ErrorCode.INVALID_JWT); + } + } + + @Override + public Authentication getAuthentication(String accessToken) { + UserDetails principal = customUserDetailsService.loadUserByUsername(getUserPk(accessToken, ACCESS_SECRET_KEY)); + return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities()); + } + + private String getUserPk(String token, Key secretKey) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + @Override + public void logout(HttpServletResponse response, String identifier) { + tokenService.deleteById(identifier); + + Cookie refreshCookie = jwtUtil.resetCookie(REFRESH_PREFIX); + response.addCookie(refreshCookie); + } +} diff --git a/src/main/java/com/genius/gitget/global/security/service/JwtGenerator.java b/src/main/java/com/genius/gitget/global/security/service/JwtGenerator.java new file mode 100644 index 00000000..c17d6d4d --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/JwtGenerator.java @@ -0,0 +1,53 @@ +package com.genius.gitget.global.security.service; + +import com.genius.gitget.challenge.user.domain.User; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtGenerator { + + public String generateAccessToken(final Key ACCESS_SECRET, final long ACCESS_EXPIRATION, User user) { + Long now = System.currentTimeMillis(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(createClaims(user)) + .setSubject(String.valueOf(user.getId())) + .setExpiration(new Date(now + ACCESS_EXPIRATION)) + .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256) + .compact(); + } + + public String generateRefreshToken(final Key REFRESH_SECRET, final long REFRESH_EXPIRATION, User user) { + Long now = System.currentTimeMillis(); + + return Jwts.builder() + .setHeader(createHeader()) + .setSubject(user.getIdentifier()) + .setExpiration(new Date(now + REFRESH_EXPIRATION)) + .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256) + .compact(); + } + + private Map createHeader() { + Map header = new HashMap<>(); + header.put("typ", "JWT"); + header.put("alg", "HS512"); + return header; + } + + private Map createClaims(User user) { + Map claims = new HashMap<>(); + claims.put("Identifier", user.getIdentifier()); + claims.put("Role", user.getRole()); + return claims; + } +} diff --git a/src/main/java/com/genius/gitget/global/security/service/JwtUtil.java b/src/main/java/com/genius/gitget/global/security/service/JwtUtil.java new file mode 100644 index 00000000..c499af54 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/JwtUtil.java @@ -0,0 +1,64 @@ +package com.genius.gitget.global.security.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.INVALID_EXPIRED_JWT; +import static com.genius.gitget.global.util.exception.ErrorCode.INVALID_JWT; +import static com.genius.gitget.global.util.exception.ErrorCode.JWT_NOT_FOUND_IN_COOKIE; + +import com.genius.gitget.global.security.constants.JwtRule; +import com.genius.gitget.global.security.constants.TokenStatus; +import com.genius.gitget.global.util.exception.BusinessException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Arrays; +import java.util.Base64; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtUtil { + + public TokenStatus getTokenStatus(String token, Key secretKey) { + try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + return TokenStatus.AUTHENTICATED; + } catch (ExpiredJwtException e) { + log.error(INVALID_EXPIRED_JWT.getMessage()); + return TokenStatus.EXPIRED; + } catch (JwtException e) { + throw new BusinessException(INVALID_JWT); + } + } + + public String resolveTokenFromCookie(Cookie[] cookies, JwtRule tokenPrefix) { + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals(tokenPrefix.getValue())) + .findFirst() + .map(Cookie::getValue) + .orElseThrow(() -> new BusinessException(JWT_NOT_FOUND_IN_COOKIE)); + } + + public Key getSigningKey(String secretKey) { + String encodedKey = encodeToBase64(secretKey); + return Keys.hmacShaKeyFor(encodedKey.getBytes(StandardCharsets.UTF_8)); + } + + private String encodeToBase64(String secretKey) { + return Base64.getEncoder().encodeToString(secretKey.getBytes()); + } + + public Cookie resetCookie(JwtRule tokenPrefix) { + Cookie cookie = new Cookie(tokenPrefix.getValue(), null); + cookie.setMaxAge(0); + cookie.setPath("/"); + return cookie; + } +} diff --git a/src/main/java/com/genius/gitget/global/security/service/TokenService.java b/src/main/java/com/genius/gitget/global/security/service/TokenService.java new file mode 100644 index 00000000..ccd2ef54 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/security/service/TokenService.java @@ -0,0 +1,38 @@ +package com.genius.gitget.global.security.service; + +import com.genius.gitget.global.security.domain.Token; +import com.genius.gitget.global.security.repository.TokenRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class TokenService { + private final TokenRepository tokenRepository; + + @Transactional + public String save(Token token) { + Token savedToken = tokenRepository.save(token); + return savedToken.getIdentifier(); + } + + public Token findByIdentifier(String identifier) { + return tokenRepository.findById(identifier) + .orElseThrow(() -> new BusinessException(ErrorCode.JWT_NOT_FOUND_IN_DB)); + } + + public boolean isRefreshHijacked(String identifier, String refreshToken) { + Token token = findByIdentifier(identifier); + return !token.getToken().equals(refreshToken); + } + + public void deleteById(String identifier) { + tokenRepository.deleteById(identifier); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/annotation/GitGetUser.java b/src/main/java/com/genius/gitget/global/util/annotation/GitGetUser.java new file mode 100644 index 00000000..bd20f7a4 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/annotation/GitGetUser.java @@ -0,0 +1,13 @@ +package com.genius.gitget.global.util.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : user") +public @interface GitGetUser { +} diff --git a/src/main/java/com/genius/gitget/global/util/config/AppConfig.java b/src/main/java/com/genius/gitget/global/util/config/AppConfig.java new file mode 100644 index 00000000..2b2ee7b0 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/config/AppConfig.java @@ -0,0 +1,58 @@ +package com.genius.gitget.global.util.config; + +import com.genius.gitget.global.file.service.FileService; +import com.genius.gitget.global.file.service.FileUtil; +import com.genius.gitget.global.file.service.LocalFileService; +import com.genius.gitget.global.file.service.S3FileService; +import com.genius.gitget.global.util.formatter.LocalDateFormatter; +import com.genius.gitget.global.util.formatter.LocalDateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.crypto.encrypt.AesBytesEncryptor; + +@Configuration +@RequiredArgsConstructor +public class AppConfig { + private final S3Config s3Config; + private final Environment env; + + + @Bean + public FileUtil fileUtil() { + return new FileUtil(); + } + + @Bean + public FileService fileManager() { + final String fileMode = env.getProperty("file.mode"); + assert fileMode != null; + + if (fileMode.equals("local")) { + final String UPLOAD_PATH = env.getProperty("file.upload.path"); + return new LocalFileService(fileUtil(), UPLOAD_PATH); + } + + final String bucket = env.getProperty("cloud.aws.s3.bucket"); + final String cloudFrontDomain = env.getProperty("cloud.aws.cloud-front.domain"); + return new S3FileService(s3Config.amazonS3Client(), fileUtil(), bucket, cloudFrontDomain); + } + + @Bean + public AesBytesEncryptor aesBytesEncryptor() { + return new AesBytesEncryptor( + env.getProperty("github.encryptSecretKey"), + env.getProperty("github.salt")); + } + + @Bean + public LocalDateFormatter localDateFormatter() { + return new LocalDateFormatter(); + } + + @Bean + public LocalDateTimeFormatter localDateTimeFormatter() { + return new LocalDateTimeFormatter(); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/config/S3Config.java b/src/main/java/com/genius/gitget/global/util/config/S3Config.java new file mode 100644 index 00000000..9472c752 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/config/S3Config.java @@ -0,0 +1,34 @@ +package com.genius.gitget.global.util.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + private final String accessKey; + private final String secretKey; + private final String region; + + public S3Config(@Value("${cloud.aws.credentials.accessKey}") String accessKey, + @Value("${cloud.aws.credentials.secretKey}") String secretKey, + @Value("${cloud.aws.region.static}") String region) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + } + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} + diff --git a/src/main/java/com/genius/gitget/global/util/config/SwaggerConfig.java b/src/main/java/com/genius/gitget/global/util/config/SwaggerConfig.java new file mode 100644 index 00000000..5e25d5b7 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/config/SwaggerConfig.java @@ -0,0 +1,19 @@ +package com.genius.gitget.global.util.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("Todoffin API") + .description("TeamTheGenius의 todoffin API 문서입니다.") + .version("1.0.0")); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/config/WebConfig.java b/src/main/java/com/genius/gitget/global/util/config/WebConfig.java new file mode 100644 index 00000000..3d0de4b4 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/config/WebConfig.java @@ -0,0 +1,15 @@ +package com.genius.gitget.global.util.config; + +import com.genius.gitget.challenge.instance.service.StringToEnum; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new StringToEnum()); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/domain/BaseTimeEntity.java b/src/main/java/com/genius/gitget/global/util/domain/BaseTimeEntity.java new file mode 100644 index 00000000..ad870e55 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/domain/BaseTimeEntity.java @@ -0,0 +1,31 @@ +package com.genius.gitget.global.util.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@ToString +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseTimeEntity { + + // User 테이블 + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime modifiedDate; + + @Column(name = "deleted_at") + private LocalDateTime deletedDate; + +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/util/exception/BusinessException.java b/src/main/java/com/genius/gitget/global/util/exception/BusinessException.java new file mode 100644 index 00000000..85099a8b --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/BusinessException.java @@ -0,0 +1,30 @@ +package com.genius.gitget.global.util.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BusinessException extends RuntimeException { + private HttpStatus status; + + public BusinessException() { + super(); + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + + public BusinessException(String message) { + super(message); + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } + + public BusinessException(Throwable cause) { + super(cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/util/exception/ErrorCode.java b/src/main/java/com/genius/gitget/global/util/exception/ErrorCode.java new file mode 100644 index 00000000..c1c2df38 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/ErrorCode.java @@ -0,0 +1,93 @@ +package com.genius.gitget.global.util.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + UUID_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 uuid입니다."), + + MEMBER_NOT_UPDATED(HttpStatus.BAD_REQUEST, "유저 정보가 업데이트되지 않았습니다."), + + LIKES_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 좋아요 목록을 찾을 수 없습니다."), + + FAILED_POINT_PAYMENT(HttpStatus.BAD_REQUEST, "결제 조건이 충족하지 않습니다."), + INVALID_PAYMENT_AMOUNT(HttpStatus.BAD_REQUEST, "최초 결제 요청 금액과 일치하지 않습니다."), + FAILED_FINAL_PAYMENT(HttpStatus.BAD_REQUEST, "최종 결제가 승인되지 않았습니다."), + INVALID_ORDERID(HttpStatus.NOT_FOUND, "해당 주문번호가 존재하지 않습니다."), + + TOPIC_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 토픽을 찾을 수 없습니다."), + TOPIC_HAVE_INSTANCE(HttpStatus.BAD_REQUEST, "해당 토픽은 인스턴스를 가지고 있으므로 삭제할 수 없습니다."), + TOPIC_IMAGE_NOT_FOUND(HttpStatus.BAD_REQUEST, "토픽 이미지가 존재하지 않습니다. 토픽 이미지를 설정해주세요."), + + INVALID_INSTANCE_DATE(HttpStatus.BAD_REQUEST, "인스턴스 생성/종료 일자는 현재 일자 이후여야 합니다."), + INSTANCE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 인스턴스를 찾을 수 없습니다."), + PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 참여 정보를 찾을 수 없습니다."), + + ALREADY_PASSED_CERTIFICATION(HttpStatus.BAD_REQUEST, "패스한 인증에 대해서는 인증 갱신할 수 없습니다."), + CERTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 인증 정보를 찾을 수 없습니다."), + + ALREADY_REGISTERED(HttpStatus.BAD_REQUEST, "이미 회원가입이 완료된 사용자입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다."), + DUPLICATED_NICKNAME(HttpStatus.BAD_REQUEST, "이미 존재하는 닉네임입니다."), + NOT_AUTHENTICATED_USER(HttpStatus.BAD_REQUEST, "인증 가능한 사용자가 아닙니다."), + + INVALID_EXPIRED_JWT(HttpStatus.BAD_REQUEST, "이미 만료된 JWT 입니다."), + INVALID_MALFORMED_JWT(HttpStatus.BAD_REQUEST, "JWT의 구조가 유효하지 않습니다."), + INVALID_CLAIM_JWT(HttpStatus.BAD_REQUEST, "JWT의 Claim이 유효하지 않습니다."), + UNSUPPORTED_JWT(HttpStatus.BAD_REQUEST, "지원하지 않는 JWT 형식입니다."), + INVALID_JWT(HttpStatus.BAD_REQUEST, "JWT가 유효하지 않습니다."), + INVALID_PROGRESS(HttpStatus.BAD_REQUEST, "존재하지 않는 정보입니다."), + + JWT_NOT_FOUND_IN_DB(HttpStatus.NOT_FOUND, "DB에 JWT 정보가 존재하지 않습니다."), + JWT_NOT_FOUND_IN_HEADER(HttpStatus.NOT_FOUND, "Header에 JWT 정보가 존재하지 않습니다."), + JWT_NOT_FOUND_IN_COOKIE(HttpStatus.NOT_FOUND, "Cookie에 JWT 정보가 존재하지 않습니다."), + REFRESH_TOKEN_NOT_MATCH(HttpStatus.BAD_REQUEST, "DB에 저장되어 있는 Refresh token과 일치하지 않습니다."), + + MULTIPART_FILE_NOT_EXIST(HttpStatus.BAD_REQUEST, "MultipartFile이 전달되지 않았습니다."), + FILE_NOT_EXIST(HttpStatus.BAD_REQUEST, "해당 파일(이미지)이 존재하지 않습니다."), + INVALID_FILE_NAME(HttpStatus.BAD_REQUEST, "전달받은 파일(이미지)의 이름이 null이거나 빈 문자열입니다."), + NOT_SUPPORTED_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 확장자입니다."), + NOT_SUPPORTED_IMAGE_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 타입입니다."), + FILE_NOT_DELETED(HttpStatus.BAD_REQUEST, "파일(이미지)이 정상적으로 삭제되지 않았습니다."), + FILE_NOT_SAVED(HttpStatus.BAD_REQUEST, "파일(이미지)가 정상적으로 저장되지 않았습니다."), + FILE_NOT_COPIED(HttpStatus.BAD_REQUEST, "파일(이미지)가 정상적으로 복사되지 않았습니다."), + IMAGE_NOT_ENCODED(HttpStatus.BAD_REQUEST, "이미지를 인코딩하는 과정에서 오류가 발생했습니다."), + FILE_MAX_SIZE_EXCEED(HttpStatus.BAD_REQUEST, "파일(이미지)의 크기가 최대 용량을 초과했습니다."), + + GITHUB_CONNECTION_FAILED(HttpStatus.BAD_REQUEST, "Github 연결이 실패했습니다."), + GITHUB_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "저장된 Github Token을 찾을 수 없습니다."), + GITHUB_ID_INCORRECT(HttpStatus.BAD_REQUEST, "소셜로그인에 사용한 Github 계정과 일치하지 않습니다."), + GITHUB_REPOSITORY_INCORRECT(HttpStatus.BAD_REQUEST, "해당 레포지토리와 연결이 되지 않습니다."), + GITHUB_PR_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 레포지토리에 PR이 존재하지 않습니다."), + + CAN_NOT_JOIN_INSTANCE(HttpStatus.BAD_REQUEST, "해당 인스턴스에 참여할 수 없습니다."), + INVALID_JOIN_DATE(HttpStatus.BAD_REQUEST, "인스턴스 시작 당일에는 신규 참여할 수 없습니다."), + CAN_NOT_QUIT_INSTANCE(HttpStatus.BAD_REQUEST, "해당 인스턴스의 참여를 취소할 수 없습니다."), + + PR_TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "인증에 사용하는 PR의 내용에 PR Template가 포함되어야 합니다."), + NOT_ACTIVITY_INSTANCE(HttpStatus.BAD_REQUEST, "진행 중인 챌린지에 대해서만 인증이 가능합니다."), + NOT_CERTIFICATE_PERIOD(HttpStatus.BAD_REQUEST, "챌린지 인증은 챌린지 진행 기간 내에만 가능합니다. 챌린지 진행 기간인지 확인해주세요."), + + NOT_ENOUGH_POINT(HttpStatus.BAD_REQUEST, "사용자의 보유 포인트가 충분하지 않습니다."), + ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "아이템 정보를 찾을 수 없습니다."), + ORDERS_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자의 아이템 보유 정보를 찾을 수 없습니다."), + HAS_NO_ITEM(HttpStatus.NOT_FOUND, "해당 아이템을 보유하고 있지 않습니다."), + CAN_NOT_USE_PASS_ITEM(HttpStatus.BAD_REQUEST, "인증 패스 아이템을 사용할 수 없는 조건입니다."), + ITEM_CATEGORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 카테고리에 맞는 아이템을 찾을 수 없습니다."), + + ALREADY_PURCHASED(HttpStatus.BAD_REQUEST, "프로필 프레임은 재구매가 불가능 합니다."), + INVALID_EQUIP_CONDITION(HttpStatus.BAD_REQUEST, "프로필 프레임을 장착할 수 있는 상태가 아닙니다."), + IN_USE_FRAME_NOT_FOUND(HttpStatus.NOT_FOUND, "사용 중인 프로필 프레임을 찾을 수 없습니다"), + TOO_MANY_USING_FRAME(HttpStatus.BAD_REQUEST, "사용 중인 프로필 프레임이 너무 많습니다. 장착 해제를 먼저 실행해주세요."), + + CAN_NOT_GET_REWARDS(HttpStatus.BAD_REQUEST, "챌린지 보상을 받을 수 있는 조건이 아닙니다."), + ALREADY_REWARDED(HttpStatus.BAD_REQUEST, "해당 챌린지 보상은 이미 지급되었습니다."); + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/genius/gitget/global/util/exception/SuccessCode.java b/src/main/java/com/genius/gitget/global/util/exception/SuccessCode.java new file mode 100644 index 00000000..e1650444 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/SuccessCode.java @@ -0,0 +1,23 @@ +package com.genius.gitget.global.util.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode { + + // 200 OK + SUCCESS(HttpStatus.OK, "요청이 정상적으로 처리되었습니다."), + JOIN_SUCCESS(HttpStatus.OK, "챌린지 참여가 정상적으로 처리되었습니다."), + QUIT_SUCCESS(HttpStatus.OK, "챌린지 참여 취소가 정상적으로 처리되었습니다."), + + // 201 CREATED + CREATED(HttpStatus.CREATED, "정상적으로 생성되었습니다."); + + + private final HttpStatus status; + private final String message; +} + diff --git a/src/main/java/com/genius/gitget/global/util/exception/handler/BusinessExceptionHandler.java b/src/main/java/com/genius/gitget/global/util/exception/handler/BusinessExceptionHandler.java new file mode 100644 index 00000000..56f98053 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/handler/BusinessExceptionHandler.java @@ -0,0 +1,38 @@ +package com.genius.gitget.global.util.exception.handler; + +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.slack.service.SlackService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class BusinessExceptionHandler implements MessageSender { + private final Environment env; + private final SlackService slackService; + + @ExceptionHandler(BusinessException.class) + protected ResponseEntity globalBusinessExceptionHandler(BusinessException e) { + log.error("[ERROR]" + e.getMessage(), e); + sendSlackMessage(e); + + return ResponseEntity.badRequest().body( + new CommonResponse(e.getStatus(), e.getMessage()) + ); + } + + @Override + public void sendSlackMessage(Exception exception) { + if (!env.matchesProfiles("prod")) { + return; + } + slackService.sendMessage(exception); + } + +} diff --git a/src/main/java/com/genius/gitget/global/util/exception/handler/FileExceptionHandler.java b/src/main/java/com/genius/gitget/global/util/exception/handler/FileExceptionHandler.java new file mode 100644 index 00000000..e2a3a83b --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/handler/FileExceptionHandler.java @@ -0,0 +1,39 @@ +package com.genius.gitget.global.util.exception.handler; + +import static com.genius.gitget.global.util.exception.ErrorCode.FILE_MAX_SIZE_EXCEED; + +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.slack.service.SlackService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class FileExceptionHandler implements MessageSender { + private final Environment env; + private final SlackService slackService; + + @ExceptionHandler(MaxUploadSizeExceededException.class) + protected ResponseEntity globalExceptionHandler(Exception e) { + log.error("Multipart 용량이 최대 크기를 초과하여 예외가 발생했습니다."); + sendSlackMessage(e); + + return ResponseEntity.badRequest().body( + new CommonResponse(HttpStatus.BAD_REQUEST, FILE_MAX_SIZE_EXCEED.getMessage())); + } + + @Override + public void sendSlackMessage(Exception exception) { + if (!env.matchesProfiles("prod")) { + return; + } + slackService.sendMessage(exception); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/genius/gitget/global/util/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..ec18ab7d --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,37 @@ +package com.genius.gitget.global.util.exception.handler; + +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.slack.service.SlackService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler implements MessageSender { + private final Environment env; + private final SlackService slackService; + + @ExceptionHandler(Exception.class) + protected ResponseEntity globalExceptionHandler(Exception e) { + log.error("예외처리 되지 않은 Exception 발생 - 처리 필요"); + log.error("[UNHANDLED ERROR] " + e.getMessage(), e); + sendSlackMessage(e); + + return ResponseEntity.badRequest().body( + new CommonResponse(HttpStatus.BAD_REQUEST, e.getMessage())); + } + + @Override + public void sendSlackMessage(Exception exception) { + if (!env.matchesProfiles("prod")) { + return; + } + slackService.sendMessage(exception); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/exception/handler/MessageSender.java b/src/main/java/com/genius/gitget/global/util/exception/handler/MessageSender.java new file mode 100644 index 00000000..aeca1ac1 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/exception/handler/MessageSender.java @@ -0,0 +1,15 @@ +package com.genius.gitget.global.util.exception.handler; + +public interface MessageSender { + /** + * 예외가 발생했을 때 Slack에 예외 발생에 대한 메세지를 보내는 메서드 + *

+ * 주의 사항!! + * 활성화 된 profile이 "prod"일 때에만 작동하도록 해야 합니다. + * Environment의 matchProfiles()를 통해 특정 프로파일이 활성화되어 있는지 확인 가능 + * if(!environment.matchesProfiles("prod")) return; + * + * @param exception 발생한 예외 + */ + void sendSlackMessage(Exception exception); +} diff --git a/src/main/java/com/genius/gitget/global/util/formatter/CommonPattern.java b/src/main/java/com/genius/gitget/global/util/formatter/CommonPattern.java new file mode 100644 index 00000000..c7964586 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/formatter/CommonPattern.java @@ -0,0 +1,8 @@ +package com.genius.gitget.global.util.formatter; + +public final class CommonPattern { + public static final String MANAGER_ID_PATTERN = "^(?=.*[a-z])(?=.*\\d)[a-z\\d]{8,16}$"; + public static final String MANAGER_PASSWORD_PATTERN = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[$@$!%*#?&])[A-Za-z\\d$@$!%*#?&]{8,16}$"; + public static final String IP_ADDRESS_PATTERN = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$"; + public static final String Y_OR_N = "^[YN]$"; +} diff --git a/src/main/java/com/genius/gitget/global/util/formatter/LocalDateFormatter.java b/src/main/java/com/genius/gitget/global/util/formatter/LocalDateFormatter.java new file mode 100644 index 00000000..3f1e376c --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/formatter/LocalDateFormatter.java @@ -0,0 +1,21 @@ +package com.genius.gitget.global.util.formatter; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import org.springframework.format.Formatter; +import org.springframework.stereotype.Component; + +@Component +public class LocalDateFormatter implements Formatter { + + @Override + public LocalDate parse(String text, Locale locale) { + return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE); + } + + @Override + public String print(LocalDate object, Locale locale) { + return DateTimeFormatter.ISO_LOCAL_DATE.format(object); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/util/formatter/LocalDateTimeFormatter.java b/src/main/java/com/genius/gitget/global/util/formatter/LocalDateTimeFormatter.java new file mode 100644 index 00000000..959b9507 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/formatter/LocalDateTimeFormatter.java @@ -0,0 +1,22 @@ +package com.genius.gitget.global.util.formatter; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; +import org.springframework.format.Formatter; +import org.springframework.stereotype.Component; + +@Component +public class LocalDateTimeFormatter implements Formatter { + + @Override + public LocalDateTime parse(String text, Locale locale) { + return LocalDateTime.parse(text, DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT)); + } + + @Override + public String print(LocalDateTime object, Locale locale) { + return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT).format(object); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/response/dto/CommonResponse.java b/src/main/java/com/genius/gitget/global/util/response/dto/CommonResponse.java new file mode 100644 index 00000000..5091e150 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/response/dto/CommonResponse.java @@ -0,0 +1,24 @@ +package com.genius.gitget.global.util.response.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.http.HttpStatus; + + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class CommonResponse { + private HttpStatus code; + private int resultCode; + private String message; + + public CommonResponse(HttpStatus code, String message) { + this.code = code; + this.resultCode = code.value(); + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/global/util/response/dto/ListResponse.java b/src/main/java/com/genius/gitget/global/util/response/dto/ListResponse.java new file mode 100644 index 00000000..441132a4 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/response/dto/ListResponse.java @@ -0,0 +1,26 @@ +package com.genius.gitget.global.util.response.dto; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + + +@Getter +@RequiredArgsConstructor +public class ListResponse extends CommonResponse { + private List dataList; + private int count; + + + public ListResponse(HttpStatus status, String message, List dataList) { + super(status, message); + this.dataList = dataList; + this.count = dataList.size(); + } + + public ListResponse(List dataList) { + this.dataList = dataList; + this.count = dataList.size(); + } +} diff --git a/src/main/java/com/genius/gitget/global/util/response/dto/PagingResponse.java b/src/main/java/com/genius/gitget/global/util/response/dto/PagingResponse.java new file mode 100644 index 00000000..bc955e84 --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/response/dto/PagingResponse.java @@ -0,0 +1,21 @@ +package com.genius.gitget.global.util.response.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public class PagingResponse extends CommonResponse { + private Page data; + + public PagingResponse(Page data) { + this.data = data; + } + + public PagingResponse(HttpStatus status, String message, Page data) { + super(status, message); + this.data = data; + } +} diff --git a/src/main/java/com/genius/gitget/global/util/response/dto/SingleResponse.java b/src/main/java/com/genius/gitget/global/util/response/dto/SingleResponse.java new file mode 100644 index 00000000..af45565f --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/response/dto/SingleResponse.java @@ -0,0 +1,20 @@ +package com.genius.gitget.global.util.response.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public class SingleResponse extends CommonResponse { + private T data; + + public SingleResponse(T data) { + this.data = data; + } + + public SingleResponse(HttpStatus status, String message, T data) { + super(status, message); + this.data = data; + } +} diff --git a/src/main/java/com/genius/gitget/global/util/response/dto/SlicingResponse.java b/src/main/java/com/genius/gitget/global/util/response/dto/SlicingResponse.java new file mode 100644 index 00000000..50955b6a --- /dev/null +++ b/src/main/java/com/genius/gitget/global/util/response/dto/SlicingResponse.java @@ -0,0 +1,21 @@ +package com.genius.gitget.global.util.response.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public class SlicingResponse extends CommonResponse { + private Slice data; + + public SlicingResponse(HttpStatus status, String message, Slice data) { + super(status, message); + this.data = data; + } + + public SlicingResponse(Slice data) { + this.data = data; + } +} diff --git a/src/main/java/com/genius/gitget/profile/controller/ProfileController.java b/src/main/java/com/genius/gitget/profile/controller/ProfileController.java new file mode 100644 index 00000000..854baad2 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/controller/ProfileController.java @@ -0,0 +1,124 @@ +package com.genius.gitget.profile.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import com.genius.gitget.profile.dto.UserChallengeResultResponse; +import com.genius.gitget.profile.dto.UserDetailsInformationResponse; +import com.genius.gitget.profile.dto.UserIndexResponse; +import com.genius.gitget.profile.dto.UserInformationRequest; +import com.genius.gitget.profile.dto.UserInformationResponse; +import com.genius.gitget.profile.dto.UserInformationUpdateRequest; +import com.genius.gitget.profile.dto.UserInterestResponse; +import com.genius.gitget.profile.dto.UserInterestUpdateRequest; +import com.genius.gitget.profile.dto.UserPointResponse; +import com.genius.gitget.profile.dto.UserSignoutRequest; +import com.genius.gitget.profile.service.ProfileFacade; +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.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("/api/profile") +public class ProfileController { + private final ProfileFacade profileFacade; + + // 마이페이지 - 사용자 상세 정보 조회 + @GetMapping + public ResponseEntity> getUserDetailsInformation( + @GitGetUser User user) { + UserDetailsInformationResponse userInformation = profileFacade.getUserDetailsInformation(user); + return ResponseEntity.ok() + .body(new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), + userInformation) + ); + } + + // 사용자 정보 조회 + @PostMapping + public ResponseEntity> getUserInformation( + @RequestBody UserInformationRequest userInformationRequest) { + UserInformationResponse userInformation = profileFacade.getUserInformation(userInformationRequest.getUserId()); + return ResponseEntity.ok() + .body(new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), + userInformation) + ); + } + + // 마이페이지 - 회원 정보 수정 + @PostMapping("/information") + public ResponseEntity> updateUserInformation( + @GitGetUser User user, + @RequestBody UserInformationUpdateRequest userInformationUpdateRequest) { + + Long userId = profileFacade.updateUserInformation(user, userInformationUpdateRequest); + UserIndexResponse userIndexResponse = new UserIndexResponse(userId); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), userIndexResponse) + ); + } + + // 마이페이지 - 관심사 조회 + @GetMapping("/interest") + public ResponseEntity> getUserInterest(@GitGetUser User user) { + UserInterestResponse userInterest = profileFacade.getUserInterest(user); + + return ResponseEntity.ok() + .body(new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), + userInterest)); + } + + + // 마이페이지 - 관심사 수정 + @PostMapping("/interest") + public ResponseEntity updateUserTags(@GitGetUser User user, + @RequestBody UserInterestUpdateRequest userInterestUpdateRequest) { + profileFacade.updateUserTags(user, userInterestUpdateRequest); + + return ResponseEntity.ok() + .body(new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage())); + } + + + // 마이페이지 - 챌린지 현황 + @GetMapping("/challenges") + public ResponseEntity> getUserChallengeResult(@GitGetUser User user) { + UserChallengeResultResponse userChallengeResult = profileFacade.getUserChallengeResult(user); + + return ResponseEntity.ok() + .body(new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), + userChallengeResult)); + } + + + // 마이페이지 - 탈퇴하기 + @DeleteMapping + public ResponseEntity deleteUserInformation(@GitGetUser User user, + @RequestBody UserSignoutRequest userSignoutRequest) { + profileFacade.deleteUserInformation(user, userSignoutRequest.getReason()); + + return ResponseEntity.ok() + .body(new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage())); + } + + + // 포인트 조회 + @GetMapping("/point") + public ResponseEntity> getUserPoint(@GitGetUser User user) { + UserPointResponse userPoint = profileFacade.getUserPoint(user); + + return ResponseEntity.ok() + .body(new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), + userPoint)); + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserChallengeResultResponse.java b/src/main/java/com/genius/gitget/profile/dto/UserChallengeResultResponse.java new file mode 100644 index 00000000..4c4278e7 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserChallengeResultResponse.java @@ -0,0 +1,20 @@ +package com.genius.gitget.profile.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +public class UserChallengeResultResponse { + private Integer fail; + private Integer success; + private Integer processing; + private Integer beforeStart; + + @Builder + public UserChallengeResultResponse(Integer fail, Integer success, Integer processing, Integer beforeStart) { + this.fail = fail; + this.success = success; + this.processing = processing; + this.beforeStart = beforeStart; + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserDetailsInformationResponse.java b/src/main/java/com/genius/gitget/profile/dto/UserDetailsInformationResponse.java new file mode 100644 index 00000000..234f30e9 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserDetailsInformationResponse.java @@ -0,0 +1,42 @@ +package com.genius.gitget.profile.dto; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.file.dto.FileResponse; +import lombok.Builder; +import lombok.Data; + +@Data +public class UserDetailsInformationResponse { + private Long userId; + private String identifier; + private String nickname; + private String information; + private Long point; + private int progressBar; + private FileResponse fileResponse; + + @Builder + public UserDetailsInformationResponse(Long userId, String identifier, String nickname, String information, + Long point, int progressBar, FileResponse fileResponse) { + this.userId = userId; + this.identifier = identifier; + this.nickname = nickname; + this.information = information; + this.point = point; + this.fileResponse = fileResponse; + this.progressBar = progressBar; + } + + public static UserDetailsInformationResponse createByEntity(User findUser, int participantCount, + FileResponse fileResponse) { + return UserDetailsInformationResponse.builder() + .userId(findUser.getId()) + .identifier(findUser.getIdentifier()) + .nickname(findUser.getNickname()) + .information(findUser.getInformation()) + .point(findUser.getPoint()) + .progressBar(participantCount) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserIndexResponse.java b/src/main/java/com/genius/gitget/profile/dto/UserIndexResponse.java new file mode 100644 index 00000000..b19399c8 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserIndexResponse.java @@ -0,0 +1,6 @@ +package com.genius.gitget.profile.dto; + +public record UserIndexResponse( + Long userId +) { +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserInformationRequest.java b/src/main/java/com/genius/gitget/profile/dto/UserInformationRequest.java new file mode 100644 index 00000000..b00360ab --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserInformationRequest.java @@ -0,0 +1,8 @@ +package com.genius.gitget.profile.dto; + +import lombok.Data; + +@Data +public class UserInformationRequest { + private Long userId; +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserInformationResponse.java b/src/main/java/com/genius/gitget/profile/dto/UserInformationResponse.java new file mode 100644 index 00000000..cc7234ef --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserInformationResponse.java @@ -0,0 +1,35 @@ +package com.genius.gitget.profile.dto; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.file.dto.FileResponse; +import lombok.Builder; +import lombok.Data; + +@Data +public class UserInformationResponse { + private Long userId; + private String identifier; + private String nickname; + private Long frameId; + private FileResponse fileResponse; + + @Builder + public UserInformationResponse(Long userId, String identifier, String nickname, Long frameId, + FileResponse fileResponse) { + this.userId = userId; + this.identifier = identifier; + this.nickname = nickname; + this.frameId = frameId; + this.fileResponse = fileResponse; + } + + public static UserInformationResponse createByEntity(User findUser, Long frameId, FileResponse fileResponse) { + return UserInformationResponse.builder() + .userId(findUser.getId()) + .identifier(findUser.getIdentifier()) + .nickname(findUser.getNickname()) + .frameId(frameId) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserInformationUpdateRequest.java b/src/main/java/com/genius/gitget/profile/dto/UserInformationUpdateRequest.java new file mode 100644 index 00000000..423e4630 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserInformationUpdateRequest.java @@ -0,0 +1,16 @@ +package com.genius.gitget.profile.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +public class UserInformationUpdateRequest { + private String nickname; + private String information; + + @Builder + public UserInformationUpdateRequest(String nickname, String information) { + this.nickname = nickname; + this.information = information; + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserInterestResponse.java b/src/main/java/com/genius/gitget/profile/dto/UserInterestResponse.java new file mode 100644 index 00000000..f7e63691 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserInterestResponse.java @@ -0,0 +1,15 @@ +package com.genius.gitget.profile.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Data; + +@Data +public class UserInterestResponse { + private List tags; + + @Builder + public UserInterestResponse(List tags) { + this.tags = tags; + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserInterestUpdateRequest.java b/src/main/java/com/genius/gitget/profile/dto/UserInterestUpdateRequest.java new file mode 100644 index 00000000..5872d917 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserInterestUpdateRequest.java @@ -0,0 +1,17 @@ +package com.genius.gitget.profile.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class UserInterestUpdateRequest { + private List tags; + + @Builder + public UserInterestUpdateRequest(List tags) { + this.tags = tags; + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserPointResponse.java b/src/main/java/com/genius/gitget/profile/dto/UserPointResponse.java new file mode 100644 index 00000000..8b2adae5 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserPointResponse.java @@ -0,0 +1,16 @@ +package com.genius.gitget.profile.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +public class UserPointResponse { + private String identifier; + private Long point; + + @Builder + public UserPointResponse(String identifier, Long point) { + this.identifier = identifier; + this.point = point; + } +} diff --git a/src/main/java/com/genius/gitget/profile/dto/UserSignoutRequest.java b/src/main/java/com/genius/gitget/profile/dto/UserSignoutRequest.java new file mode 100644 index 00000000..82d32b1b --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/dto/UserSignoutRequest.java @@ -0,0 +1,18 @@ +package com.genius.gitget.profile.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor(access = AccessLevel.PROTECTED) + +public class UserSignoutRequest { + private String reason; + + @Builder + public UserSignoutRequest(String reason) { + this.reason = reason; + } +} diff --git a/src/main/java/com/genius/gitget/profile/service/ProfileFacade.java b/src/main/java/com/genius/gitget/profile/service/ProfileFacade.java new file mode 100644 index 00000000..bd3ecef5 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/service/ProfileFacade.java @@ -0,0 +1,29 @@ +package com.genius.gitget.profile.service; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.profile.dto.UserChallengeResultResponse; +import com.genius.gitget.profile.dto.UserDetailsInformationResponse; +import com.genius.gitget.profile.dto.UserInformationResponse; +import com.genius.gitget.profile.dto.UserInformationUpdateRequest; +import com.genius.gitget.profile.dto.UserInterestResponse; +import com.genius.gitget.profile.dto.UserInterestUpdateRequest; +import com.genius.gitget.profile.dto.UserPointResponse; + +public interface ProfileFacade { + public UserPointResponse getUserPoint(User user); + + public UserInformationResponse getUserInformation(Long userId); + + public UserDetailsInformationResponse getUserDetailsInformation(User user); + + public Long updateUserInformation(User user, UserInformationUpdateRequest userInformationUpdateRequest); + + public void deleteUserInformation(User user, String reason); + + public void updateUserTags(User user, UserInterestUpdateRequest userInterestUpdateRequest); + + public UserInterestResponse getUserInterest(User user); + + public UserChallengeResultResponse getUserChallengeResult(User user); + +} diff --git a/src/main/java/com/genius/gitget/profile/service/ProfileFacadeService.java b/src/main/java/com/genius/gitget/profile/service/ProfileFacadeService.java new file mode 100644 index 00000000..0f93fb61 --- /dev/null +++ b/src/main/java/com/genius/gitget/profile/service/ProfileFacadeService.java @@ -0,0 +1,161 @@ +package com.genius.gitget.profile.service; + +import static com.genius.gitget.challenge.participant.domain.JoinResult.FAIL; +import static com.genius.gitget.challenge.participant.domain.JoinResult.PROCESSING; +import static com.genius.gitget.challenge.participant.domain.JoinResult.READY; +import static com.genius.gitget.challenge.participant.domain.JoinResult.SUCCESS; +import static com.genius.gitget.challenge.participant.domain.JoinStatus.YES; + +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.profile.dto.UserChallengeResultResponse; +import com.genius.gitget.profile.dto.UserDetailsInformationResponse; +import com.genius.gitget.profile.dto.UserInformationResponse; +import com.genius.gitget.profile.dto.UserInformationUpdateRequest; +import com.genius.gitget.profile.dto.UserInterestResponse; +import com.genius.gitget.profile.dto.UserInterestUpdateRequest; +import com.genius.gitget.profile.dto.UserPointResponse; +import com.genius.gitget.store.item.service.OrdersService; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class ProfileFacadeService implements ProfileFacade { + private final UserService userService; + private final OrdersService ordersService; + private final FilesManager filesManager; + + public ProfileFacadeService(UserService userService, OrdersService ordersService, + FilesManager filesManager) { + this.userService = userService; + this.ordersService = ordersService; + this.filesManager = filesManager; + } + + @Override + public UserPointResponse getUserPoint(User user) { + Long userPoint = userService.getUserPoint(user.getId()); + return UserPointResponse.builder() + .identifier(user.getIdentifier()) + .point(userPoint) + .build(); + } + + @Override + public UserInformationResponse getUserInformation(Long userId) { + User findUser = userService.findUserById(userId); + Long frameId = ordersService.getUsingFrameItem(userId).getId(); + FileResponse fileResponse = filesManager.convertToFileResponse(findUser.getFiles()); + return UserInformationResponse.createByEntity(findUser, frameId, fileResponse); + } + + @Override + public UserDetailsInformationResponse getUserDetailsInformation(User user) { + User findUser = userService.findByIdentifier(user.getIdentifier()); + + int participantCount = 0; + List participantInfoList = findUser.getParticipantList(); + + for (int i = 0; i < participantInfoList.size(); i++) { + if (participantInfoList.get(i).getJoinStatus() == YES) { + JoinResult joinResult = participantInfoList.get(i).getJoinResult(); + participantCount = (joinResult == SUCCESS) ? participantCount + 1 : participantCount - 1; + } + } + FileResponse fileResponse = filesManager.convertToFileResponse(findUser.getFiles()); + return UserDetailsInformationResponse.createByEntity(findUser, participantCount, fileResponse); + } + + @Override + @Transactional + public Long updateUserInformation(User user, UserInformationUpdateRequest userInformationUpdateRequest) { + User findUser = userService.findByIdentifier(user.getIdentifier()); + findUser.updateUserInformation( + userInformationUpdateRequest.getNickname(), + userInformationUpdateRequest.getInformation()); + + return userService.save(findUser); + } + + @Override + @Transactional + public void deleteUserInformation(User user, String reason) { + User findUser = userService.findByIdentifier(user.getIdentifier()); + + filesManager.deleteFile(findUser.getFiles()); + findUser.setFiles(null); + findUser.deleteLikesList(); + + userService.delete(findUser.getId(), user.getIdentifier(), reason); + } + + @Override + @Transactional + public void updateUserTags(User user, UserInterestUpdateRequest userInterestUpdateRequest) { + if (userInterestUpdateRequest.getTags() == null) { + throw new BusinessException(); + } + User findUser = userService.findByIdentifier(user.getIdentifier()); + String interest = String.join(",", userInterestUpdateRequest.getTags()); + findUser.updateUserTags(interest); + userService.save(findUser); + } + + @Override + public UserInterestResponse getUserInterest(User user) { + String tags = user.getTags(); + String[] tagsList = tags.split(","); + for (int i = 0; i < tagsList.length; i++) { + tagsList[i] = tagsList[i].trim(); + } + List interestList = new ArrayList<>(Arrays.asList(tagsList)); + return UserInterestResponse.builder() + .tags(interestList) + .build(); + } + + @Override + public UserChallengeResultResponse getUserChallengeResult(User user) { + User findUser = userService.findByIdentifier(user.getIdentifier()); + HashMap> participantHashMap = new HashMap<>() { + { + put(READY, new ArrayList<>()); + put(FAIL, new ArrayList<>()); + put(PROCESSING, new ArrayList<>()); + put(SUCCESS, new ArrayList<>()); + } + }; + List participantInfos = findUser.getParticipantList(); + for (Participant participant : participantInfos) { + JoinResult joinResult = participant.getJoinResult(); + if (!participantHashMap.containsKey(joinResult)) { + continue; + } + + List participantIds = participantHashMap.get(joinResult); + participantIds.add(participant.getId()); + participantHashMap.put(joinResult, participantIds); + } + + int beforeStart = participantHashMap.get(READY).size(); + int fail = participantHashMap.get(FAIL).size(); + int success = participantHashMap.get(SUCCESS).size(); + int processing = participantHashMap.get(PROCESSING).size(); + + return UserChallengeResultResponse.builder() + .beforeStart(beforeStart) + .fail(fail) + .success(success) + .processing(processing) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/schedule/controller/ProgressController.java b/src/main/java/com/genius/gitget/schedule/controller/ProgressController.java new file mode 100644 index 00000000..27cd183b --- /dev/null +++ b/src/main/java/com/genius/gitget/schedule/controller/ProgressController.java @@ -0,0 +1,32 @@ +package com.genius.gitget.schedule.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.schedule.service.ProgressService; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class ProgressController { + private final ProgressService scheduleService; + + @GetMapping("/challenges/update") + public ResponseEntity updateProgress() { + LocalDate currentDate = DateUtil.convertToKST(LocalDateTime.now()); + scheduleService.updateToActivity(currentDate); + scheduleService.updateToDone(currentDate); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } +} diff --git a/src/main/java/com/genius/gitget/schedule/service/ProgressService.java b/src/main/java/com/genius/gitget/schedule/service/ProgressService.java new file mode 100644 index 00000000..2aaf8147 --- /dev/null +++ b/src/main/java/com/genius/gitget/schedule/service/ProgressService.java @@ -0,0 +1,98 @@ +package com.genius.gitget.schedule.service; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.CERTIFICATED; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.PASSED; + +import com.genius.gitget.challenge.certification.service.CertificationService; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.Participant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ProgressService { + private final CertificationService certificationService; + private final InstanceRepository instanceRepository; + private final double SUCCESS_THRESHOLD = 85; + + @Transactional + public void updateToActivity(LocalDate currentDate) { + List preActivities = instanceRepository.findAllByProgress(Progress.PREACTIVITY); + for (Instance preActivity : preActivities) { + LocalDate startedDate = preActivity.getStartedDate().toLocalDate(); + LocalDate completedDate = preActivity.getCompletedDate().toLocalDate(); + + if (isUpdatableToActivity(startedDate, currentDate) && currentDate.isBefore(completedDate)) { + updateActivityInstance(preActivity); + } + } + } + + private boolean isUpdatableToActivity(LocalDate startedDate, LocalDate currentDate) { + return currentDate.isEqual(startedDate) || currentDate.isAfter(startedDate); + } + + private void updateActivityInstance(Instance preActivity) { + preActivity.updateProgress(Progress.ACTIVITY); + for (Participant participant : preActivity.getParticipantList()) { + participant.updateJoinResult(JoinResult.PROCESSING); + } + } + + @Transactional + public void updateToDone(LocalDate currentDate) { + List instances = new ArrayList<>(); + instances.addAll(instanceRepository.findAllByProgress(Progress.PREACTIVITY)); + instances.addAll(instanceRepository.findAllByProgress(Progress.ACTIVITY)); + + for (Instance instance : instances) { + LocalDate startedDate = instance.getStartedDate().toLocalDate(); + LocalDate completedDate = instance.getCompletedDate().toLocalDate(); + + if (currentDate.isAfter(startedDate) && currentDate.isAfter(completedDate)) { + updateDoneInstance(instance, currentDate); + } + } + } + + private void updateDoneInstance(Instance instance, LocalDate currentDate) { + instance.updateProgress(Progress.DONE); + for (Participant participant : instance.getParticipantList()) { + int totalAttempt = instance.getTotalAttempt(); + int successAttempt = getSuccessAttempt(participant.getId(), currentDate); + + JoinResult joinResult = getJoinResult(totalAttempt, successAttempt); + participant.updateJoinResult(joinResult); + } + } + + private JoinResult getJoinResult(int totalAttempt, int successAttempt) { + double successPercent = getSuccessPercent(successAttempt, totalAttempt); + if (successPercent >= SUCCESS_THRESHOLD) { + return JoinResult.SUCCESS; + } + return JoinResult.FAIL; + } + + private int getSuccessAttempt(Long participantId, LocalDate currentDate) { + int certificated = certificationService.countByStatus(participantId, CERTIFICATED, currentDate); + int passed = certificationService.countByStatus(participantId, PASSED, currentDate); + return certificated + passed; + } + + private double getSuccessPercent(int successCount, int totalCount) { + double successPercent = (double) successCount / (double) totalCount * 100; + return Math.round(successPercent * 100 / 100.0); + } +} diff --git a/src/main/java/com/genius/gitget/schedule/service/ScheduleService.java b/src/main/java/com/genius/gitget/schedule/service/ScheduleService.java new file mode 100644 index 00000000..5d695a8f --- /dev/null +++ b/src/main/java/com/genius/gitget/schedule/service/ScheduleService.java @@ -0,0 +1,29 @@ +package com.genius.gitget.schedule.service; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ScheduleService { + private final ProgressService scheduleService; + + @Transactional + @Scheduled(cron = "${schedule.cron}") + public void run() { + LocalDate kstDate = DateUtil.convertToKST(LocalDateTime.now()); + + log.info(kstDate + ": Schedule 설정에 따라 instance의 Progress 업데이트 진행"); + + scheduleService.updateToActivity(kstDate); + scheduleService.updateToDone(kstDate); + } +} diff --git a/src/main/java/com/genius/gitget/signout/Signout.java b/src/main/java/com/genius/gitget/signout/Signout.java new file mode 100644 index 00000000..0590f5dc --- /dev/null +++ b/src/main/java/com/genius/gitget/signout/Signout.java @@ -0,0 +1,43 @@ +package com.genius.gitget.signout; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@Table(name = "signout") +public class Signout { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "signout_id") + private Long id; + + private String identifier; + + private String reason; + + @CreatedDate + @Column(name = "signout_at", updatable = false) + private LocalDateTime signoutDate; + + @Builder + public Signout(String identifier, String reason, LocalDateTime signoutDate) { + this.identifier = identifier; + this.reason = reason; + this.signoutDate = signoutDate; + } +} diff --git a/src/main/java/com/genius/gitget/signout/SignoutRepository.java b/src/main/java/com/genius/gitget/signout/SignoutRepository.java new file mode 100644 index 00000000..1d8e1ca0 --- /dev/null +++ b/src/main/java/com/genius/gitget/signout/SignoutRepository.java @@ -0,0 +1,10 @@ +package com.genius.gitget.signout; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface SignoutRepository extends JpaRepository { + + @Query("select s from Signout s where s.identifier = :identifier") + Signout findByIdentifier(String identifier); +} diff --git a/src/main/java/com/genius/gitget/slack/service/SlackMessageUtil.java b/src/main/java/com/genius/gitget/slack/service/SlackMessageUtil.java new file mode 100644 index 00000000..e845129b --- /dev/null +++ b/src/main/java/com/genius/gitget/slack/service/SlackMessageUtil.java @@ -0,0 +1,61 @@ +package com.genius.gitget.slack.service; + +import static com.slack.api.model.block.Blocks.divider; +import static com.slack.api.model.block.Blocks.section; +import static com.slack.api.model.block.composition.BlockCompositions.markdownText; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.slack.api.model.Attachment; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.TextObject; +import java.util.ArrayList; +import java.util.List; + +public class SlackMessageUtil { + + private static final String ERROR_TITLE = "*Exception 발생 시각:* "; + private static final String ERROR_MESSAGE = "*Exception Message:*\n"; + private static final String ERROR_STACK = "*Exception Stack:*\n"; + private static final String FILTER_STRING = "gitget"; + + + public static String createErrorTitle() { + return ERROR_TITLE + DateUtil.getKstLocalTime(); + } + + public static List createAttachments(String color, List data) { + List attachments = new ArrayList<>(); + Attachment attachment = new Attachment(); + attachment.setColor(color); + attachment.setBlocks(data); + attachments.add(attachment); + return attachments; + } + + public static List createProdErrorMessage(Exception exception) { + StackTraceElement[] stacks = exception.getStackTrace(); + + List layoutBlockList = new ArrayList<>(); + + List sectionInFields = new ArrayList<>(); + sectionInFields.add(markdownText(ERROR_MESSAGE + exception.getMessage())); + sectionInFields.add(markdownText(ERROR_STACK + exception)); + layoutBlockList.add(section(section -> section.fields(sectionInFields))); + + layoutBlockList.add(divider()); + layoutBlockList.add(section(section -> section.text(markdownText(filterErrorStack(stacks))))); + return layoutBlockList; + } + + private static String filterErrorStack(StackTraceElement[] stacks) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("```"); + for (StackTraceElement stack : stacks) { + if (stack.toString().contains(FILTER_STRING)) { + stringBuilder.append(stack).append("\n"); + } + } + stringBuilder.append("```"); + return stringBuilder.toString(); + } +} diff --git a/src/main/java/com/genius/gitget/slack/service/SlackService.java b/src/main/java/com/genius/gitget/slack/service/SlackService.java new file mode 100644 index 00000000..d191ea20 --- /dev/null +++ b/src/main/java/com/genius/gitget/slack/service/SlackService.java @@ -0,0 +1,7 @@ +package com.genius.gitget.slack.service; + +public interface SlackService { + void sendMessage(String message); + + void sendMessage(Exception exception); +} diff --git a/src/main/java/com/genius/gitget/slack/service/SlackServiceImpl.java b/src/main/java/com/genius/gitget/slack/service/SlackServiceImpl.java new file mode 100644 index 00000000..a82d3bed --- /dev/null +++ b/src/main/java/com/genius/gitget/slack/service/SlackServiceImpl.java @@ -0,0 +1,62 @@ +package com.genius.gitget.slack.service; + +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.model.Attachment; +import com.slack.api.model.block.LayoutBlock; +import java.io.IOException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SlackServiceImpl implements SlackService { + private static final String ATTACHMENTS_ERROR_COLOR = "#eb4034"; + @Value("${slack.token}") + private String token; + @Value("${slack.channel}") + private String channel; + + @Override + public void sendMessage(String message) { + try { + MethodsClient methods = Slack.getInstance().methods(token); + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channel) + .text(message) + .build(); + + methods.chatPostMessage(request); + + } catch (SlackApiException | IOException e) { + log.error(e.getMessage()); + } + } + + @Override + public void sendMessage(Exception exception) { + try { + String errorTitle = SlackMessageUtil.createErrorTitle(); + List layoutBlocks = SlackMessageUtil.createProdErrorMessage(exception); + List attachments = SlackMessageUtil.createAttachments(ATTACHMENTS_ERROR_COLOR, + layoutBlocks); + + MethodsClient methods = Slack.getInstance().methods(token); + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channel) + .attachments(attachments) + .text(errorTitle) + .build(); + + methods.chatPostMessage(request); + log.info("slack 메세지 전송 성공"); + + } catch (SlackApiException | IOException e) { + log.error(e.getMessage()); + } + } +} diff --git a/src/main/java/com/genius/gitget/store/item/controller/StoreController.java b/src/main/java/com/genius/gitget/store/item/controller/StoreController.java new file mode 100644 index 00000000..3aab21e3 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/controller/StoreController.java @@ -0,0 +1,80 @@ +package com.genius.gitget.store.item.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.ListResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.dto.ItemResponse; +import com.genius.gitget.store.item.dto.OrderResponse; +import com.genius.gitget.store.item.dto.ProfileResponse; +import com.genius.gitget.store.item.facade.StoreFacade; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class StoreController { + private final StoreFacade storeFacade; + + @GetMapping("/items") + public ResponseEntity> getItemList( + @GitGetUser User user, + @RequestParam String category + ) { + ItemCategory itemCategory = ItemCategory.findCategory(category); + List itemResponses = storeFacade.getItemsByCategory(user, itemCategory); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), itemResponses) + ); + } + + @PostMapping("/items/order/{identifier}") + public ResponseEntity> purchaseItem( + @GitGetUser User user, + @PathVariable int identifier + ) { + ItemResponse itemResponse = storeFacade.orderItem(user, identifier); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), itemResponse) + ); + } + + @PostMapping("/items/use/{identifier}") + public ResponseEntity useItem( + @GitGetUser User user, + @PathVariable int identifier, + @RequestParam(required = false) Long instanceId + ) { + OrderResponse orderResponse = storeFacade.useItem(user, identifier, + instanceId, DateUtil.convertToKST(LocalDateTime.now())); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), orderResponse) + ); + } + + @PostMapping("/items/unuse") + public ResponseEntity> unmountItem(@GitGetUser User user) { + List profileResponses = storeFacade.unmountFrame(user); + + return ResponseEntity.ok().body( + new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), profileResponses) + ); + } +} diff --git a/src/main/java/com/genius/gitget/store/item/domain/EquipStatus.java b/src/main/java/com/genius/gitget/store/item/domain/EquipStatus.java new file mode 100644 index 00000000..c0a13bd2 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/domain/EquipStatus.java @@ -0,0 +1,15 @@ +package com.genius.gitget.store.item.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum EquipStatus { + UNAVAILABLE("장착 불가"), + AVAILABLE("장착 가능"), + IN_USE("장착 중"); + + private final String tag; +} + diff --git a/src/main/java/com/genius/gitget/store/item/domain/Item.java b/src/main/java/com/genius/gitget/store/item/domain/Item.java new file mode 100644 index 00000000..c95d4115 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/domain/Item.java @@ -0,0 +1,52 @@ +package com.genius.gitget.store.item.domain; + +import com.genius.gitget.global.util.domain.BaseTimeEntity; +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.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Item extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "item_id") + private Long id; + + @OneToMany(mappedBy = "item") + private List ordersList = new ArrayList<>(); + + @Column(unique = true) + private Integer identifier; + + private String name; + + private int cost; + + @Enumerated(EnumType.STRING) + private ItemCategory itemCategory; + + private String details; + + @Builder + public Item(String name, int cost, Integer identifier, + ItemCategory itemCategory, String details) { + this.name = name; + this.cost = cost; + this.identifier = identifier; + this.itemCategory = itemCategory; + this.details = details; + } +} diff --git a/src/main/java/com/genius/gitget/store/item/domain/ItemCategory.java b/src/main/java/com/genius/gitget/store/item/domain/ItemCategory.java new file mode 100644 index 00000000..1013ca37 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/domain/ItemCategory.java @@ -0,0 +1,27 @@ +package com.genius.gitget.store.item.domain; + +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum ItemCategory { + PROFILE_FRAME("profile-frame", "프레임"), + CERTIFICATION_PASSER("certification-passer", "인증 패스권"), + POINT_MULTIPLIER("point-multiplier", "챌린지 보상 획득 2배 아이템"); + + private final String tag; + private final String name; + + public static ItemCategory findCategory(String category) { + String lowerCase = category.trim().toLowerCase(); + + return Arrays.stream(ItemCategory.values()) + .filter(itemCategory -> itemCategory.tag.equals(lowerCase)) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.ITEM_CATEGORY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/genius/gitget/store/item/domain/Orders.java b/src/main/java/com/genius/gitget/store/item/domain/Orders.java new file mode 100644 index 00000000..b759960f --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/domain/Orders.java @@ -0,0 +1,108 @@ +package com.genius.gitget.store.item.domain; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +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 lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Orders { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "orders_id") + Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "item_id") + private Item item; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private int count; + + @Enumerated(value = EnumType.STRING) + private EquipStatus equipStatus; + + private Orders(int count, EquipStatus equipStatus) { + this.count = count; + this.equipStatus = equipStatus; + } + + public static Orders of(User user, Item item) { + Orders orders; + if (item.getItemCategory() == ItemCategory.PROFILE_FRAME) { + orders = new Orders(0, EquipStatus.AVAILABLE); + } else { + orders = new Orders(0, EquipStatus.UNAVAILABLE); + } + orders.setUserAndItem(user, item); + return orders; + } + + public static Orders of(int count, ItemCategory itemCategory) { + if (itemCategory == ItemCategory.PROFILE_FRAME) { + return new Orders(count, EquipStatus.AVAILABLE); + } + return new Orders(count, EquipStatus.UNAVAILABLE); + } + + //=== 비지니스 로직 ===// + public boolean hasItem() { + return this.count > 0; + } + + public int purchase() { + if (this.item.getItemCategory() == ItemCategory.PROFILE_FRAME && hasItem()) { + throw new BusinessException(ErrorCode.ALREADY_PURCHASED); + } + this.count++; + return count; + } + + public void useItem() { + if (!hasItem()) { + throw new BusinessException(ErrorCode.HAS_NO_ITEM); + } + this.count -= 1; + } + + public void updateEquipStatus(EquipStatus equipStatus) { + this.equipStatus = equipStatus; + } + + //=== 연관관계 편의 메서드 ===// + public void setUserAndItem(User user, Item item) { + setUser(user); + setItem(item); + } + + public void setUser(User user) { + this.user = user; + if (!user.getOrdersList().contains(this)) { + user.getOrdersList().add(this); + } + } + + public void setItem(Item item) { + this.item = item; + if (!item.getOrdersList().contains(this)) { + item.getOrdersList().add(this); + } + } +} diff --git a/src/main/java/com/genius/gitget/store/item/domain/PurchaseType.java b/src/main/java/com/genius/gitget/store/item/domain/PurchaseType.java new file mode 100644 index 00000000..4935d2ae --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/domain/PurchaseType.java @@ -0,0 +1,8 @@ +package com.genius.gitget.store.item.domain; + +import lombok.Getter; + +@Getter +public enum PurchaseType { + POINTS, ITEM +} diff --git a/src/main/java/com/genius/gitget/store/item/dto/ItemResponse.java b/src/main/java/com/genius/gitget/store/item/dto/ItemResponse.java new file mode 100644 index 00000000..d89b3749 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/dto/ItemResponse.java @@ -0,0 +1,28 @@ +package com.genius.gitget.store.item.dto; + +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import lombok.Data; + +@Data +public class ItemResponse { + private int itemId; + private ItemCategory itemCategory; + private String name; + private String details; + private int cost; + private int count; + + protected ItemResponse(Item item, int count) { + this.itemId = item.getIdentifier(); + this.itemCategory = item.getItemCategory(); + this.name = item.getName(); + this.details = item.getDetails(); + this.cost = item.getCost(); + this.count = count; + } + + public static ItemResponse create(Item item, int count) { + return new ItemResponse(item, count); + } +} diff --git a/src/main/java/com/genius/gitget/store/item/dto/OrderResponse.java b/src/main/java/com/genius/gitget/store/item/dto/OrderResponse.java new file mode 100644 index 00000000..3b498822 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/dto/OrderResponse.java @@ -0,0 +1,21 @@ +package com.genius.gitget.store.item.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class OrderResponse { + Long itemId; + + public OrderResponse() { + } + + public OrderResponse(Long itemId) { + this.itemId = itemId; + } + + public static OrderResponse of(Long itemId) { + return new OrderResponse(itemId); + } +} diff --git a/src/main/java/com/genius/gitget/store/item/dto/ProfileResponse.java b/src/main/java/com/genius/gitget/store/item/dto/ProfileResponse.java new file mode 100644 index 00000000..f31df1b1 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/dto/ProfileResponse.java @@ -0,0 +1,25 @@ +package com.genius.gitget.store.item.dto; + +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.Orders; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ProfileResponse extends ItemResponse { + private String equipStatus; + + public ProfileResponse(Item item, int numOfItem, String equipStatus) { + super(item, numOfItem); + this.equipStatus = equipStatus; + } + + public static ProfileResponse create(Item item, int numOfItem, String equipStatus) { + return new ProfileResponse(item, numOfItem, equipStatus); + } + + public static ProfileResponse createByEntity(Orders orders) { + return create(orders.getItem(), orders.getCount(), orders.getEquipStatus().getTag()); + } +} diff --git a/src/main/java/com/genius/gitget/store/item/facade/StoreFacade.java b/src/main/java/com/genius/gitget/store/item/facade/StoreFacade.java new file mode 100644 index 00000000..5402f8f8 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/facade/StoreFacade.java @@ -0,0 +1,27 @@ +package com.genius.gitget.store.item.facade; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.dto.ItemResponse; +import com.genius.gitget.store.item.dto.OrderResponse; +import com.genius.gitget.store.item.dto.ProfileResponse; +import java.time.LocalDate; +import java.util.List; + +public interface StoreFacade { + + List getItemsByCategory(User user, ItemCategory itemCategory); + + ItemResponse orderItem(User user, int itemIdentifier); + + OrderResponse useItem(User user, int itemIdentifier, Long instanceId, LocalDate currentDate); + + OrderResponse useFrameItem(Long userId, Orders orders); + + OrderResponse usePasserItem(Orders orders, Long instanceId, LocalDate currentDate); + + OrderResponse useMultiplierItem(Orders orders, Long instanceId, LocalDate currentDate); + + List unmountFrame(User user); +} diff --git a/src/main/java/com/genius/gitget/store/item/facade/StoreFacadeService.java b/src/main/java/com/genius/gitget/store/item/facade/StoreFacadeService.java new file mode 100644 index 00000000..2c76d748 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/facade/StoreFacadeService.java @@ -0,0 +1,179 @@ +package com.genius.gitget.store.item.facade; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; + +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.service.CertificationService; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.service.InstanceService; +import com.genius.gitget.challenge.myChallenge.dto.DoneResponse; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.service.ParticipantService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.service.UserService; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.EquipStatus; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.dto.ItemResponse; +import com.genius.gitget.store.item.dto.OrderResponse; +import com.genius.gitget.store.item.dto.ProfileResponse; +import com.genius.gitget.store.item.service.ItemService; +import com.genius.gitget.store.item.service.OrdersService; +import com.genius.gitget.store.payment.domain.Payment; +import com.genius.gitget.store.payment.repository.PaymentRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class StoreFacadeService implements StoreFacade { + private final ItemService itemService; + private final OrdersService ordersService; + + private final UserService userService; + private final InstanceService instanceService; + private final ParticipantService participantService; + + private final CertificationService certificationService; + private final PaymentRepository paymentRepository; + + + @Override + public List getItemsByCategory(User user, ItemCategory itemCategory) { + List itemResponses = new ArrayList<>(); + List items = itemService.findAllByCategory(itemCategory); + for (Item item : items) { + int numOfItem = ordersService.countNumOfItem(user, item.getId()); + ItemResponse itemResponse = getItemResponse(user, item, numOfItem); + itemResponses.add(itemResponse); + } + + return itemResponses; + } + + private ItemResponse getItemResponse(User user, Item item, int numOfItem) { + if (item.getItemCategory() == ItemCategory.PROFILE_FRAME) { + EquipStatus equipStatus = ordersService.getEquipStatus(user.getId(), item.getId()); + return ProfileResponse.create(item, numOfItem, equipStatus.getTag()); + } + return ItemResponse.create(item, numOfItem); + } + + @Override + @Transactional + public ItemResponse orderItem(User user, int identifier) { + User persistUser = userService.findUserById(user.getId()); + Item item = itemService.findByIdentifier(identifier); + + persistUser.hasEnoughPoint(item.getCost()); + + paymentRepository.save(Payment.create(persistUser, item)); + + Orders orders = ordersService.findOrSave(persistUser, item); + int numOfItem = orders.purchase(); + persistUser.updatePoints((long) item.getCost() * -1); + + return getItemResponse(persistUser, item, numOfItem); + } + + @Override + @Transactional + public OrderResponse useItem(User user, int identifier, Long instanceId, LocalDate currentDate) { + Item item = itemService.findByIdentifier(identifier); + Orders orders = ordersService.findByOrderInfo(user.getId(), item.getId()); + + if (!orders.hasItem()) { + throw new BusinessException(ErrorCode.HAS_NO_ITEM); + } + + switch (item.getItemCategory()) { + case PROFILE_FRAME -> { + return useFrameItem(user.getId(), orders); + } + case CERTIFICATION_PASSER -> { + return usePasserItem(orders, instanceId, currentDate); + } + case POINT_MULTIPLIER -> { + return useMultiplierItem(orders, instanceId, currentDate); + } + } + throw new BusinessException(ErrorCode.ORDERS_NOT_FOUND); + } + + @Override + public OrderResponse useFrameItem(Long userId, Orders orders) { + validateFrameEquip(userId, orders); + orders.updateEquipStatus(EquipStatus.IN_USE); + + return new OrderResponse(orders.getItem().getId()); + } + + private void validateFrameEquip(Long userId, Orders orders) { + List allUsingFrames = ordersService.findAllUsingFrames(userId); + if (!allUsingFrames.isEmpty()) { + throw new BusinessException(ErrorCode.TOO_MANY_USING_FRAME); + } + if (!orders.hasItem()) { + throw new BusinessException(ErrorCode.HAS_NO_ITEM); + } + if (orders.getEquipStatus() != EquipStatus.AVAILABLE) { + throw new BusinessException(ErrorCode.INVALID_EQUIP_CONDITION); + } + } + + @Override + public OrderResponse usePasserItem(Orders orders, Long instanceId, LocalDate currentDate) { + Long userId = orders.getUser().getId(); + Long itemId = orders.getItem().getId(); + + Instance instance = instanceService.findInstanceById(instanceId); + Participant participant = participantService.findByJoinInfo(userId, instanceId); + + Certification certification = certificationService.findOrSave(participant, NOT_YET, currentDate); + + instance.validateCertificateCondition(currentDate); + certification.validatePassCondition(); + + certification.updateToPass(currentDate); + + ordersService.useItem(orders); + return OrderResponse.of(itemId); + } + + @Override + public OrderResponse useMultiplierItem(Orders orders, Long instanceId, LocalDate currentDate) { + User user = orders.getUser(); + Instance instance = instanceService.findInstanceById(instanceId); + Participant participant = participantService.findByJoinInfo(user.getId(), instanceId); + + int rewardPoints = instance.getPointPerPerson() * 2; + participantService.getRewards(participant, rewardPoints); + ordersService.useItem(orders); + + return DoneResponse.builder() + .rewardedPoints(rewardPoints) + .build(); + } + + @Override + public List unmountFrame(User user) { + List profileResponses = new ArrayList<>(); + List frameOrders = ordersService.findAllUsingFrames(user.getId()); + + for (Orders frameOrder : frameOrders) { + ordersService.validateUnmountCondition(frameOrder); + frameOrder.updateEquipStatus(EquipStatus.AVAILABLE); + profileResponses.add(ProfileResponse.createByEntity(frameOrder)); + } + + return profileResponses; + } +} diff --git a/src/main/java/com/genius/gitget/store/item/repository/ItemRepository.java b/src/main/java/com/genius/gitget/store/item/repository/ItemRepository.java new file mode 100644 index 00000000..067e47a1 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/repository/ItemRepository.java @@ -0,0 +1,17 @@ +package com.genius.gitget.store.item.repository; + +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +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 ItemRepository extends JpaRepository { + + @Query("select i from Item i where i.itemCategory = :category") + List findAllByCategory(@Param("category") ItemCategory itemCategory); + + Optional findByIdentifier(@Param("identifier") int identifier); +} diff --git a/src/main/java/com/genius/gitget/store/item/repository/OrdersRepository.java b/src/main/java/com/genius/gitget/store/item/repository/OrdersRepository.java new file mode 100644 index 00000000..aa5804e1 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/repository/OrdersRepository.java @@ -0,0 +1,20 @@ +package com.genius.gitget.store.item.repository; + +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +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 OrdersRepository extends JpaRepository { + + @Query("select u from Orders u where u.user.id = :userId and u.item.itemCategory = :category") + List findAllByCategory(@Param("userId") Long userId, + @Param("category") ItemCategory category); + + @Query("select u from Orders u where u.user.id = :userId and u.item.id = :itemId") + Optional findByOrderInfo(@Param("userId") Long userId, + @Param("itemId") Long itemId); +} diff --git a/src/main/java/com/genius/gitget/store/item/service/ItemService.java b/src/main/java/com/genius/gitget/store/item/service/ItemService.java new file mode 100644 index 00000000..1d25130f --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/service/ItemService.java @@ -0,0 +1,32 @@ +package com.genius.gitget.store.item.service; + +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.repository.ItemRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ItemService { + private final ItemRepository itemRepository; + + public Item findById(Long itemId) { + return itemRepository.findById(itemId) + .orElseThrow(() -> new BusinessException(ErrorCode.ITEM_NOT_FOUND)); + } + + public Item findByIdentifier(int identifier) { + return itemRepository.findByIdentifier(identifier) + .orElseThrow(() -> new BusinessException(ErrorCode.ITEM_NOT_FOUND)); + } + + public List findAllByCategory(ItemCategory itemCategory) { + return itemRepository.findAllByCategory(itemCategory); + } +} diff --git a/src/main/java/com/genius/gitget/store/item/service/OrdersService.java b/src/main/java/com/genius/gitget/store/item/service/OrdersService.java new file mode 100644 index 00000000..6d981a3d --- /dev/null +++ b/src/main/java/com/genius/gitget/store/item/service/OrdersService.java @@ -0,0 +1,103 @@ +package com.genius.gitget.store.item.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.ORDERS_NOT_FOUND; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.EquipStatus; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.repository.OrdersRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class OrdersService { + private final OrdersRepository ordersRepository; + + + @Transactional + public Orders save(Orders orders) { + return ordersRepository.save(orders); + } + + @Transactional + public void delete(Orders orders) { + ordersRepository.delete(orders); + } + + public Optional findOptionalByOrderInfo(Long userId, Long itemId) { + return ordersRepository.findByOrderInfo(userId, itemId); + } + + public Orders findByOrderInfo(Long userId, Long itemId) { + return ordersRepository.findByOrderInfo(userId, itemId) + .orElseThrow(() -> new BusinessException(ORDERS_NOT_FOUND)); + } + + @Transactional + public Orders findOrSave(User user, Item item) { + return ordersRepository.findByOrderInfo(user.getId(), item.getId()) + .orElseGet(() -> ordersRepository.save(Orders.of(user, item))); + } + + public List findAllUsingFrames(Long userId) { + List frames = ordersRepository.findAllByCategory(userId, ItemCategory.PROFILE_FRAME); + return frames.stream() + .filter(frameOrder -> frameOrder.getEquipStatus() == EquipStatus.IN_USE) + .toList(); + } + + public EquipStatus getEquipStatus(Long userId, Long itemId) { + Optional optionalUserItem = ordersRepository.findByOrderInfo(userId, itemId); + if (optionalUserItem.isPresent()) { + return optionalUserItem.get().getEquipStatus(); + } + return EquipStatus.UNAVAILABLE; + } + + public Item getUsingFrameItem(Long userId) { + List usingFrames = findAllUsingFrames(userId); + if (usingFrames.size() > 1) { + throw new BusinessException(ErrorCode.TOO_MANY_USING_FRAME); + } + + if (usingFrames.isEmpty()) { + return Item.builder() + .itemCategory(ItemCategory.PROFILE_FRAME) + .identifier(null) + .build(); + } + return usingFrames.get(0).getItem(); + } + + @Transactional + public void useItem(Orders orders) { + orders.useItem(); + if (!orders.hasItem()) { + delete(orders); + } + } + + public int countNumOfItem(User user, Long itemId) { + Optional optionalUserItem = ordersRepository.findByOrderInfo(user.getId(), itemId); + return optionalUserItem.map(Orders::getCount) + .orElse(0); + } + + public void validateUnmountCondition(Orders orders) { + if (orders.getItem().getItemCategory() != ItemCategory.PROFILE_FRAME) { + throw new BusinessException(ErrorCode.ITEM_NOT_FOUND); + } + if (orders.getEquipStatus() != EquipStatus.IN_USE) { + throw new BusinessException(ErrorCode.IN_USE_FRAME_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/config/TossPaymentConfig.java b/src/main/java/com/genius/gitget/store/payment/config/TossPaymentConfig.java new file mode 100644 index 00000000..32c0c75d --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/config/TossPaymentConfig.java @@ -0,0 +1,23 @@ +package com.genius.gitget.store.payment.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +public class TossPaymentConfig { + @Value("{payment.toss.test_client_api_key}") + private String testClientApiKey; + + @Value("{payment.toss.test_secrete_api_key}") + private String testSecretKey; + + @Value("{payment.toss.success_url}") + private String successUrl; + + @Value("{payment.toss.fail_url}") + private String failUrl; + + public static final String URL = "https://api.tosspayments.com/v1/payments/confirm"; +} diff --git a/src/main/java/com/genius/gitget/store/payment/controller/PaymentController.java b/src/main/java/com/genius/gitget/store/payment/controller/PaymentController.java new file mode 100644 index 00000000..b38ae9ef --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/controller/PaymentController.java @@ -0,0 +1,38 @@ +package com.genius.gitget.store.payment.controller; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.exception.SuccessCode; +import com.genius.gitget.global.util.response.dto.PagingResponse; +import com.genius.gitget.store.payment.dto.PaymentDetailsResponse; +import com.genius.gitget.store.payment.service.PaymentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/payment") +@Slf4j +public class PaymentController { + + private final PaymentService paymentService; + + @GetMapping + public ResponseEntity> getPaymentDetails(@GitGetUser User user, + @PageableDefault + Pageable pageable) { + + Page paymentDetails = paymentService.getPaymentDetails(user, pageable); + + return ResponseEntity.ok().body( + new PagingResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), paymentDetails) + ); + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/controller/PaymentTossController.java b/src/main/java/com/genius/gitget/store/payment/controller/PaymentTossController.java new file mode 100644 index 00000000..0cfd9d9f --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/controller/PaymentTossController.java @@ -0,0 +1,55 @@ +package com.genius.gitget.store.payment.controller; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.annotation.GitGetUser; +import com.genius.gitget.global.util.exception.SuccessCode; +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import com.genius.gitget.store.payment.dto.PaymentFailRequest; +import com.genius.gitget.store.payment.dto.PaymentRequest; +import com.genius.gitget.store.payment.dto.PaymentResponse; +import com.genius.gitget.store.payment.dto.PaymentSuccessRequest; +import com.genius.gitget.store.payment.dto.PaymentSuccessResponse; +import com.genius.gitget.store.payment.service.PaymentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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("/api/payment/toss") +@Slf4j +public class PaymentTossController { + + private final PaymentService paymentService; + + @PostMapping + public ResponseEntity> requestTossPayment( + @GitGetUser User user, + @RequestBody PaymentRequest paymentRequest) { + PaymentResponse paymentResponse = paymentService.requestTossPayment(user, paymentRequest); + return ResponseEntity.ok().body( + new SingleResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), paymentResponse) + ); + } + + @PostMapping("/success") + public ResponseEntity> tossPaymentSuccess( + @RequestBody PaymentSuccessRequest paymentSuccessRequest) throws Exception { + PaymentSuccessResponse successResponse = paymentService.tossPaymentSuccess(paymentSuccessRequest); + return ResponseEntity.ok().body( + new SingleResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), successResponse) + ); + } + + @PostMapping("/fail") + public ResponseEntity tossPaymentFail(@RequestBody PaymentFailRequest paymentFailRequest) { + paymentService.tossPaymentFail(paymentFailRequest); + return ResponseEntity.ok().body(new CommonResponse( + SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage())); + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/domain/OrderType.java b/src/main/java/com/genius/gitget/store/payment/domain/OrderType.java new file mode 100644 index 00000000..f174f659 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/domain/OrderType.java @@ -0,0 +1,14 @@ +package com.genius.gitget.store.payment.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum OrderType { + POINT("points", "포인트 충전"), + ITEM("items", "아이템 구매"); + + private final String key; + private final String value; +} diff --git a/src/main/java/com/genius/gitget/store/payment/domain/Payment.java b/src/main/java/com/genius/gitget/store/payment/domain/Payment.java new file mode 100644 index 00000000..f8b7ee6f --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/domain/Payment.java @@ -0,0 +1,97 @@ +package com.genius.gitget.store.payment.domain; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.util.domain.BaseTimeEntity; +import com.genius.gitget.store.item.domain.Item; +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.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicInsert; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@Table(name = "payment") +public class Payment extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private String orderId; + + private String paymentKey; + + private Long amount; + + private Long pointAmount; + + private String orderName; + + private boolean isSuccess; + + private String failReason; + + @Column(name = "success_at", updatable = false) + private LocalDateTime successDate; + + @Enumerated(EnumType.STRING) + private OrderType orderType; + + + @Builder + public Payment(String orderId, String paymentKey, Long amount, Long pointAmount, String orderName, + boolean isSuccess, String failReason, User user, OrderType orderType) { + this.orderId = orderId; + this.paymentKey = paymentKey; + this.amount = amount; + this.pointAmount = pointAmount; + this.orderName = orderName; + this.isSuccess = isSuccess; + this.failReason = failReason; + this.user = user; + this.orderType = orderType; + } + + public static Payment create(User user, Item item) { + return Payment.builder() + .user(user) + .orderType(OrderType.ITEM) + .isSuccess(true) + .pointAmount(Long.parseLong(String.valueOf(item.getCost()))) + .orderName(item.getName()) + .build(); + } + + public void setPaymentSuccessStatus(String paymentKey, boolean isSuccess) { + this.paymentKey = paymentKey; + this.isSuccess = isSuccess; + } + + public void setPaymentFailStatus(String message, boolean isSuccess) { + this.failReason = message; + this.isSuccess = isSuccess; + } + + public void setUser(User user) { + this.user = user; + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/dto/PaymentDetailsResponse.java b/src/main/java/com/genius/gitget/store/payment/dto/PaymentDetailsResponse.java new file mode 100644 index 00000000..9ca44bdf --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/dto/PaymentDetailsResponse.java @@ -0,0 +1,47 @@ +package com.genius.gitget.store.payment.dto; + +import com.genius.gitget.store.payment.domain.OrderType; +import com.genius.gitget.store.payment.domain.Payment; +import lombok.Builder; +import lombok.Data; + +@Data +public class PaymentDetailsResponse { + private String orderType; + private String orderName; + private String orderLocalDate; + private String orderDayOfWeek; + private String increasedPoint; + private String decreasedPoint; + private String chargingCash; + + + @Builder + public PaymentDetailsResponse(String orderType, String orderName, String orderLocalDate, String orderDayOfWeek, + String increasedPoint, String decreasedPoint, String chargingCash) { + this.orderType = orderType; + this.orderName = orderName; + this.orderLocalDate = orderLocalDate; + this.orderDayOfWeek = orderDayOfWeek; + this.increasedPoint = increasedPoint; + this.decreasedPoint = decreasedPoint; + this.chargingCash = chargingCash; + } + + public static PaymentDetailsResponse createByEntity(Payment payment, String paymentDateFormat, + String dayOfWeekKorean) { + return PaymentDetailsResponse.builder() + .orderType(String.valueOf(payment.getOrderType().getValue())) + .orderName(payment.getOrderName()) + .orderLocalDate(paymentDateFormat) + .orderDayOfWeek(dayOfWeekKorean) + .decreasedPoint( + payment.getOrderType().equals(OrderType.ITEM) ? String.valueOf(payment.getPointAmount()) : null) + .increasedPoint( + payment.getOrderType().equals(OrderType.POINT) ? String.valueOf(payment.getPointAmount()) + : null) + .chargingCash( + payment.getOrderType().equals(OrderType.POINT) ? String.valueOf(payment.getAmount()) : null) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/dto/PaymentFailRequest.java b/src/main/java/com/genius/gitget/store/payment/dto/PaymentFailRequest.java new file mode 100644 index 00000000..e2d05b87 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/dto/PaymentFailRequest.java @@ -0,0 +1,9 @@ +package com.genius.gitget.store.payment.dto; + +import lombok.Data; + +@Data +public class PaymentFailRequest { + private String message; + private String orderId; +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/store/payment/dto/PaymentRequest.java b/src/main/java/com/genius/gitget/store/payment/dto/PaymentRequest.java new file mode 100644 index 00000000..93e813a1 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/dto/PaymentRequest.java @@ -0,0 +1,35 @@ +package com.genius.gitget.store.payment.dto; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.store.payment.domain.Payment; +import java.util.UUID; +import lombok.Builder; +import lombok.Data; + +@Data +public class PaymentRequest { + private Long amount; + private String orderName; + private Long pointAmount; + private String userEmail; + + @Builder + public PaymentRequest(Long amount, String orderName, Long pointAmount, String userEmail) { + this.amount = amount; + this.orderName = orderName; + this.pointAmount = pointAmount; + this.userEmail = userEmail; + } + + public Payment paymentRequestToEntity(User user, PaymentRequest paymentRequest) { + return Payment.builder() + .orderId(UUID.randomUUID().toString()) + .amount(paymentRequest.getAmount()) + .orderName(paymentRequest.getOrderName()) + .pointAmount(paymentRequest.getPointAmount()) + .user(user) + .isSuccess(false) + .failReason("") + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/dto/PaymentResponse.java b/src/main/java/com/genius/gitget/store/payment/dto/PaymentResponse.java new file mode 100644 index 00000000..74e17fa4 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/dto/PaymentResponse.java @@ -0,0 +1,34 @@ +package com.genius.gitget.store.payment.dto; + +import com.genius.gitget.store.payment.domain.Payment; +import lombok.Builder; +import lombok.Data; + +@Data +public class PaymentResponse { + private Long amount; + private Long pointAmount; + private String orderName; + private String orderId; + private String userEmail; + + @Builder + public PaymentResponse(Long amount, Long pointAmount, String orderName, String orderId, String userEmail) { + this.amount = amount; + this.pointAmount = pointAmount; + this.orderName = orderName; + this.orderId = orderId; + this.userEmail = userEmail; + } + + public static PaymentResponse createByEntity(Payment payment) { + + return PaymentResponse.builder() + .amount(payment.getAmount()) + .pointAmount(payment.getPointAmount()) + .orderName(payment.getOrderName()) + .orderId(payment.getOrderId()) + .userEmail(payment.getUser().getIdentifier()) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/dto/PaymentSuccessRequest.java b/src/main/java/com/genius/gitget/store/payment/dto/PaymentSuccessRequest.java new file mode 100644 index 00000000..2ff49410 --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/dto/PaymentSuccessRequest.java @@ -0,0 +1,10 @@ +package com.genius.gitget.store.payment.dto; + +import lombok.Data; + +@Data +public class PaymentSuccessRequest { + private String orderId; + private String paymentKey; + private String amount; +} diff --git a/src/main/java/com/genius/gitget/store/payment/dto/PaymentSuccessResponse.java b/src/main/java/com/genius/gitget/store/payment/dto/PaymentSuccessResponse.java new file mode 100644 index 00000000..f4c09ddd --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/dto/PaymentSuccessResponse.java @@ -0,0 +1,41 @@ +package com.genius.gitget.store.payment.dto; + + +import com.genius.gitget.store.payment.domain.Payment; +import lombok.Builder; +import lombok.Data; + +@Data +public class PaymentSuccessResponse { + + private String orderId; + private String paymentKey; + private Long amount; + private Long pointAmount; + private String orderName; + private String isSuccess; + private String failReason; + + @Builder + public PaymentSuccessResponse(String orderId, String paymentKey, Long amount, Long pointAmount, String orderName, + boolean isSuccess, String failReason) { + this.orderId = orderId; + this.paymentKey = paymentKey; + this.amount = amount; + this.pointAmount = pointAmount; + this.orderName = orderName; + this.isSuccess = String.valueOf(isSuccess); + this.failReason = failReason; + } + + public static PaymentSuccessResponse createByEntity(Payment payment) { + return PaymentSuccessResponse.builder() + .paymentKey(payment.getPaymentKey()) + .amount(payment.getAmount()) + .orderName(payment.getOrderName()) + .pointAmount(payment.getPointAmount()) + .orderId(payment.getOrderId()) + .isSuccess(payment.isSuccess()) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/store/payment/repository/PaymentRepository.java b/src/main/java/com/genius/gitget/store/payment/repository/PaymentRepository.java new file mode 100644 index 00000000..1e108f6a --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/repository/PaymentRepository.java @@ -0,0 +1,14 @@ +package com.genius.gitget.store.payment.repository; + +import com.genius.gitget.store.payment.domain.Payment; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface PaymentRepository extends JpaRepository { + Optional findByOrderId(String orderId); + + @Query("select p from Payment p where p.user.id = :id order by p.createdDate desc") + List findPaymentDetailsByUserId(Long id); +} diff --git a/src/main/java/com/genius/gitget/store/payment/service/PaymentService.java b/src/main/java/com/genius/gitget/store/payment/service/PaymentService.java new file mode 100644 index 00000000..1866092e --- /dev/null +++ b/src/main/java/com/genius/gitget/store/payment/service/PaymentService.java @@ -0,0 +1,187 @@ +package com.genius.gitget.store.payment.service; + +import static java.lang.Long.valueOf; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.payment.config.TossPaymentConfig; +import com.genius.gitget.store.payment.domain.Payment; +import com.genius.gitget.store.payment.dto.PaymentDetailsResponse; +import com.genius.gitget.store.payment.dto.PaymentFailRequest; +import com.genius.gitget.store.payment.dto.PaymentRequest; +import com.genius.gitget.store.payment.dto.PaymentResponse; +import com.genius.gitget.store.payment.dto.PaymentSuccessRequest; +import com.genius.gitget.store.payment.dto.PaymentSuccessResponse; +import com.genius.gitget.store.payment.repository.PaymentRepository; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final UserRepository userRepository; + private final TossPaymentConfig tossPaymentConfig; + + @Transactional + public PaymentResponse requestTossPayment(User user, PaymentRequest paymentRequest) { + if (!user.getIdentifier().equals(paymentRequest.getUserEmail())) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + if (paymentRequest.getAmount() < 100L) { + throw new BusinessException(ErrorCode.FAILED_POINT_PAYMENT); + } + if (!(paymentRequest.getAmount() == 1000L || paymentRequest.getAmount() == 3000L + || paymentRequest.getAmount() == 5000L || paymentRequest.getAmount() == 7000L)) { + throw new BusinessException(ErrorCode.FAILED_POINT_PAYMENT); + } + Payment requestToEntity = paymentRequest.paymentRequestToEntity(user, paymentRequest); + Payment savedPayment = paymentRepository.save(requestToEntity); + return PaymentResponse.createByEntity(savedPayment); + } + + + @Transactional + public PaymentSuccessResponse tossPaymentSuccess(PaymentSuccessRequest paymentSuccessRequest) throws Exception { + Payment payment = verifyPayment(paymentSuccessRequest.getOrderId(), + valueOf(paymentSuccessRequest.getAmount())); + PaymentSuccessResponse result = requestPaymentAccept(paymentSuccessRequest); + payment.setPaymentSuccessStatus(paymentSuccessRequest.getPaymentKey(), true); + + User user = verifyUser(payment.getUser()); + user.updatePoints(payment.getPointAmount()); + + return result; + } + + @Transactional + public PaymentSuccessResponse requestPaymentAccept(PaymentSuccessRequest paymentSuccessRequest) throws Exception { + String orderId; + String amount; + String paymentKey; + + paymentKey = paymentSuccessRequest.getPaymentKey(); + orderId = paymentSuccessRequest.getOrderId(); + amount = String.valueOf(paymentSuccessRequest.getAmount()); + + HashMap hashMap = new HashMap<>(); + hashMap.put("paymentKey", paymentKey); + hashMap.put("orderId", orderId); + hashMap.put("amount", String.valueOf(amount)); + + JSONObject obj = new JSONObject(hashMap); + + String widgetSecretKey = tossPaymentConfig.getTestSecretKey(); + Base64.Encoder encoder = Base64.getEncoder(); + byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes(StandardCharsets.UTF_8)); + String authorizations = "Basic " + new String(encodedBytes); + + URL url = new URL(TossPaymentConfig.URL); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("Authorization", authorizations); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + OutputStream outputStream = connection.getOutputStream(); + outputStream.write(obj.toString().getBytes(StandardCharsets.UTF_8)); + + int code = connection.getResponseCode(); + boolean isSuccess = code == 200; + + InputStream responseStream = isSuccess ? connection.getInputStream() : connection.getErrorStream(); + + Reader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8); + + JSONParser parser = new JSONParser(); + JSONObject jsonObject = (JSONObject) parser.parse(reader); + responseStream.close(); + + Payment payment = paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_ORDERID)); + + if (!((jsonObject.get("orderId") != null && jsonObject.get("orderId") == payment.getOrderId()) + && (jsonObject.get("paymentKey") != null && jsonObject.get("paymentKey") == payment.getPaymentKey()))) { + throw new BusinessException(ErrorCode.FAILED_FINAL_PAYMENT); + } + + return PaymentSuccessResponse.createByEntity(payment); + } + + public Payment verifyPayment(String orderId, Long amount) { + Payment payment = paymentRepository.findByOrderId(orderId).orElseThrow(() -> new BusinessException( + ErrorCode.MEMBER_NOT_FOUND)); + if (amount < 100L) { + throw new BusinessException(ErrorCode.FAILED_POINT_PAYMENT); + } + if (payment.getAmount().equals(amount)) { + Long pointAmount = payment.getPointAmount(); + if (pointAmount == (amount / 10L) && (pointAmount * 10L) == amount) { + return payment; + } + } + throw new BusinessException(ErrorCode.INVALID_PAYMENT_AMOUNT); + } + + public void tossPaymentFail(PaymentFailRequest paymentFailRequest) { + Payment payment = paymentRepository.findByOrderId(paymentFailRequest.getOrderId()) + .orElseThrow(() -> new BusinessException( + ErrorCode.FAILED_FINAL_PAYMENT)); + payment.setPaymentFailStatus(paymentFailRequest.getMessage(), false); + } + + public Page getPaymentDetails(User user, Pageable pageable) { + User findUser = verifyUser(user); + List payments = paymentRepository.findPaymentDetailsByUserId(findUser.getId()); + + List paymentDetailsResponses = new ArrayList<>(); + + for (Payment payment : payments) { + LocalDateTime paymentDate = payment.getCreatedDate(); + + // YYYY-MM-dd + String paymentDateFormat = paymentDate.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")); + + // 요일 구하기 + DayOfWeek dayOfWeek = paymentDate.getDayOfWeek(); + String dayOfWeekKorean = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN); + + paymentDetailsResponses.add( + PaymentDetailsResponse.createByEntity(payment, paymentDateFormat, dayOfWeekKorean)); + } + return new PageImpl<>(paymentDetailsResponses, pageable, paymentDetailsResponses.size()); + } + + private User verifyUser(User user) { + return userRepository.findByIdentifier(user.getIdentifier()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/topic/controller/TopicController.java b/src/main/java/com/genius/gitget/topic/controller/TopicController.java new file mode 100644 index 00000000..85d36eb4 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/controller/TopicController.java @@ -0,0 +1,95 @@ +package com.genius.gitget.topic.controller; + +import static com.genius.gitget.global.util.exception.SuccessCode.CREATED; +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; + +import com.genius.gitget.global.util.response.dto.CommonResponse; +import com.genius.gitget.global.util.response.dto.PagingResponse; +import com.genius.gitget.global.util.response.dto.SingleResponse; +import com.genius.gitget.topic.dto.TopicCreateRequest; +import com.genius.gitget.topic.dto.TopicDetailResponse; +import com.genius.gitget.topic.dto.TopicIndexResponse; +import com.genius.gitget.topic.dto.TopicPagingResponse; +import com.genius.gitget.topic.dto.TopicUpdateRequest; +import com.genius.gitget.topic.facade.TopicFacade; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.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("/api/admin/topic") +public class TopicController { + private final TopicFacade topicFacade; + + // 토픽 리스트 요청 + @GetMapping + public ResponseEntity> getAllTopics( + @PageableDefault(size = 5, direction = Sort.Direction.ASC) Pageable pageable) { + + Page topicPagingResponse = topicFacade.findTopics(pageable); + return ResponseEntity.ok().body( + new PagingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), topicPagingResponse) + ); + } + + // 토픽 상세 정보 요청 + @GetMapping("/{id}") + public ResponseEntity> getTopicById(@PathVariable Long id) { + TopicDetailResponse topicDetailResponse = topicFacade.findOne(id); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), topicDetailResponse) + ); + } + + // 토픽 생성 요청 + @PostMapping + public ResponseEntity> createTopic( + @RequestBody TopicCreateRequest topicCreateRequest) { + + Long topic = topicFacade.create(topicCreateRequest); + TopicIndexResponse topicUpdateResponse = new TopicIndexResponse(topic); + + return ResponseEntity.ok().body( + new SingleResponse<>( + CREATED.getStatus(), CREATED.getMessage(), topicUpdateResponse) + ); + } + + // 토픽 수정 요청 + @PatchMapping("/{id}") + public ResponseEntity> updateTopic( + @PathVariable Long id, + @RequestBody TopicUpdateRequest topicUpdateRequest) { + + Long updateTopic = topicFacade.update(id, topicUpdateRequest); + TopicIndexResponse topicUpdateResponse = new TopicIndexResponse(updateTopic); + + return ResponseEntity.ok().body( + new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), topicUpdateResponse) + ); + } + + // 토픽 삭제 요청 + @DeleteMapping("/{id}") + public ResponseEntity deleteTopic(@PathVariable Long id) { + + topicFacade.delete(id); + + return ResponseEntity.ok().body( + new CommonResponse(SUCCESS.getStatus(), SUCCESS.getMessage()) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/topic/domain/Topic.java b/src/main/java/com/genius/gitget/topic/domain/Topic.java new file mode 100644 index 00000000..7758d313 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/domain/Topic.java @@ -0,0 +1,85 @@ +package com.genius.gitget.topic.domain; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.global.file.domain.FileHolder; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.util.domain.BaseTimeEntity; +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.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "topic") +public class Topic extends BaseTimeEntity implements FileHolder { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "topic_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "files_id") + private Files files; + + @OneToMany(mappedBy = "topic") + private List instanceList = new ArrayList<>(); + + private String title; + + private String description; + + private String tags; + + private String notice; + + private int pointPerPerson; + + + @Builder + public Topic(String title, String description, String tags, String notice, int pointPerPerson) { + this.title = title; + this.description = description; + this.tags = tags; + this.notice = notice; + this.pointPerPerson = pointPerPerson; + } + + public void updateExistInstance(String description) { + this.description = description; + } + + public void updateNotExistInstance(String title, String description, String tags, String notice, + int pointPerPerson) { + this.title = title; + this.description = description; + this.tags = tags; + this.notice = notice; + this.pointPerPerson = pointPerPerson; + } + + @Override + public Optional getFiles() { + return Optional.ofNullable(this.files); + } + + @Override + public void setFiles(Files files) { + this.files = files; + } +} \ No newline at end of file diff --git a/src/main/java/com/genius/gitget/topic/dto/TopicCreateRequest.java b/src/main/java/com/genius/gitget/topic/dto/TopicCreateRequest.java new file mode 100644 index 00000000..4af0369b --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/dto/TopicCreateRequest.java @@ -0,0 +1,23 @@ +package com.genius.gitget.topic.dto; + +import com.genius.gitget.topic.domain.Topic; +import lombok.Builder; + +@Builder +public record TopicCreateRequest( + String title, + String tags, + String description, + int pointPerPerson, + String notice +) { + public static Topic from(TopicCreateRequest topicCreateRequest) { + return Topic.builder() + .title(topicCreateRequest.title()) + .description(topicCreateRequest.description()) + .tags(topicCreateRequest.tags()) + .pointPerPerson(topicCreateRequest.pointPerPerson()) + .notice(topicCreateRequest.notice()) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/topic/dto/TopicDetailResponse.java b/src/main/java/com/genius/gitget/topic/dto/TopicDetailResponse.java new file mode 100644 index 00000000..c13e4693 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/dto/TopicDetailResponse.java @@ -0,0 +1,28 @@ +package com.genius.gitget.topic.dto; + +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.topic.domain.Topic; +import lombok.Builder; + +@Builder +public record TopicDetailResponse( + Long topicId, + String title, + String tags, + String description, + String notice, + int pointPerPerson, + FileResponse fileResponse) { + + public static TopicDetailResponse of(Topic topic, FileResponse fileResponse) { + return TopicDetailResponse.builder() + .topicId(topic.getId()) + .title(topic.getTitle()) + .tags(topic.getTags()) + .description(topic.getDescription()) + .notice(topic.getNotice()) + .pointPerPerson(topic.getPointPerPerson()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/topic/dto/TopicIndexResponse.java b/src/main/java/com/genius/gitget/topic/dto/TopicIndexResponse.java new file mode 100644 index 00000000..e7d5769d --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/dto/TopicIndexResponse.java @@ -0,0 +1,6 @@ +package com.genius.gitget.topic.dto; + +public record TopicIndexResponse( + Long topicId +) { +} diff --git a/src/main/java/com/genius/gitget/topic/dto/TopicPagingResponse.java b/src/main/java/com/genius/gitget/topic/dto/TopicPagingResponse.java new file mode 100644 index 00000000..7b673edb --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/dto/TopicPagingResponse.java @@ -0,0 +1,17 @@ +package com.genius.gitget.topic.dto; + +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.topic.domain.Topic; +import lombok.Builder; + +@Builder +public record TopicPagingResponse(Long topicId, String title, FileResponse fileResponse) { + + public static TopicPagingResponse of(Topic topic, FileResponse fileResponse) { + return TopicPagingResponse.builder() + .topicId(topic.getId()) + .title(topic.getTitle()) + .fileResponse(fileResponse) + .build(); + } +} diff --git a/src/main/java/com/genius/gitget/topic/dto/TopicUpdateRequest.java b/src/main/java/com/genius/gitget/topic/dto/TopicUpdateRequest.java new file mode 100644 index 00000000..d0326bc2 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/dto/TopicUpdateRequest.java @@ -0,0 +1,13 @@ +package com.genius.gitget.topic.dto; + +import lombok.Builder; + +@Builder +public record TopicUpdateRequest( + String title, + String tags, + String description, + int pointPerPerson, + String notice +) { +} diff --git a/src/main/java/com/genius/gitget/topic/facade/TopicFacade.java b/src/main/java/com/genius/gitget/topic/facade/TopicFacade.java new file mode 100644 index 00000000..da46ad92 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/facade/TopicFacade.java @@ -0,0 +1,21 @@ +package com.genius.gitget.topic.facade; + +import com.genius.gitget.topic.dto.TopicCreateRequest; +import com.genius.gitget.topic.dto.TopicDetailResponse; +import com.genius.gitget.topic.dto.TopicPagingResponse; +import com.genius.gitget.topic.dto.TopicUpdateRequest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface TopicFacade { + + Page findTopics(Pageable pageable); + + TopicDetailResponse findOne(Long id); + + Long create(TopicCreateRequest topicCreateRequest); + + Long update(Long id, TopicUpdateRequest topicUpdateRequest); + + void delete(Long id); +} diff --git a/src/main/java/com/genius/gitget/topic/facade/TopicFacadeService.java b/src/main/java/com/genius/gitget/topic/facade/TopicFacadeService.java new file mode 100644 index 00000000..b58254a0 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/facade/TopicFacadeService.java @@ -0,0 +1,68 @@ +package com.genius.gitget.topic.facade; + +import com.genius.gitget.global.file.dto.FileResponse; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.dto.TopicCreateRequest; +import com.genius.gitget.topic.dto.TopicDetailResponse; +import com.genius.gitget.topic.dto.TopicPagingResponse; +import com.genius.gitget.topic.dto.TopicUpdateRequest; +import com.genius.gitget.topic.service.TopicService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +@Transactional +public class TopicFacadeService implements TopicFacade { + + private final FilesManager filesManager; + private final TopicService topicService; + + + @Override + public Page findTopics(Pageable pageable) { + Page findTopics = topicService.findTopics(pageable); + return findTopics.map(this::convertToTopicPagingResponseDto); + } + + @Override + public TopicDetailResponse findOne(Long id) { + Topic findTopic = topicService.findOne(id); + FileResponse fileResponse = filesManager.convertToFileResponse(findTopic.getFiles()); + return TopicDetailResponse.of(findTopic, fileResponse); + } + + @Override + public Long create(TopicCreateRequest topicCreateRequest) { + Topic topic = TopicCreateRequest.from(topicCreateRequest); + return topicService.create(topic); + } + + @Override + public Long update(Long id, TopicUpdateRequest topicUpdateRequest) { + Topic topic = topicService.findOne(id); + + if (!topic.getInstanceList().isEmpty()) { + topic.updateExistInstance(topicUpdateRequest.description()); + return topicService.create(topic); + } + + topic.updateNotExistInstance(topicUpdateRequest.title(), topicUpdateRequest.description(), + topicUpdateRequest.tags(), topicUpdateRequest.notice(), topicUpdateRequest.pointPerPerson()); + return topicService.create(topic); + } + + @Override + public void delete(Long id) { + topicService.delete(id); + } + + private TopicPagingResponse convertToTopicPagingResponseDto(Topic topic) { + FileResponse fileResponse = filesManager.convertToFileResponse(topic.getFiles()); + return TopicPagingResponse.of(topic, fileResponse); + } +} diff --git a/src/main/java/com/genius/gitget/topic/repository/TopicRepository.java b/src/main/java/com/genius/gitget/topic/repository/TopicRepository.java new file mode 100644 index 00000000..3ab98006 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/repository/TopicRepository.java @@ -0,0 +1,12 @@ +package com.genius.gitget.topic.repository; + +import com.genius.gitget.topic.domain.Topic; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface TopicRepository extends JpaRepository { + @Query("select t from Topic t ORDER BY t.id DESC ") + Page findAllById(Pageable pageable); +} diff --git a/src/main/java/com/genius/gitget/topic/service/TopicService.java b/src/main/java/com/genius/gitget/topic/service/TopicService.java new file mode 100644 index 00000000..9a450145 --- /dev/null +++ b/src/main/java/com/genius/gitget/topic/service/TopicService.java @@ -0,0 +1,42 @@ +package com.genius.gitget.topic.service; + +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TopicService { + private final TopicRepository topicRepository; + + public Page findTopics(Pageable pageable) { + return topicRepository.findAllById(pageable); + } + + public Topic findOne(Long id) { + return topicRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.TOPIC_NOT_FOUND)); + } + + @Transactional + public Long create(Topic topic) { + Topic savedTopic = topicRepository.save(topic); + return savedTopic.getId(); + } + + @Transactional + public void delete(Long id) { + Topic topic = topicRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.TOPIC_NOT_FOUND)); + topicRepository.delete(topic); + } +} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 00000000..a101bb92 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,57 @@ +INSERT INTO item (identifier, cost, details, name, item_category) +SELECT * +FROM (SELECT 1 AS identifier, + 100 AS cost, + '프로필을 꾸밀 수 있는 프레임입니다.' AS details, + '성탄절 프레임' AS name, + 'PROFILE_FRAME' AS item_category + UNION ALL + SELECT 2, + 100, + '프로필을 꾸밀 수 있는 프레임입니다.', + '어둠의 힘 프레임', + 'PROFILE_FRAME' + UNION ALL + SELECT 3, + 100, + '오늘의 인증을 넘길 수 있는 아이템입니다.', + '인증 패스권', + 'CERTIFICATION_PASSER' + UNION ALL + SELECT 4, + 100, + '아이템 사용 시, 챌린지 성공 보상을 2배로 획득할 수 있는 아이템입니다.', + '챌린지 보상 획득 2배 아이템', + 'POINT_MULTIPLIER' + + UNION ALL + SELECT 5, + 100, + '프로필을 꾸밀 수 있는 프레임입니다.', + '불태워라 프레임', + 'PROFILE_FRAME' + UNION ALL + SELECT 6, + 100, + '프로필을 꾸밀 수 있는 프레임입니다.', + '끈적이는 프레임', + 'PROFILE_FRAME' + UNION ALL + SELECT 7, + 100, + '프로필을 꾸밀 수 있는 프레임입니다.', + '무섭지롱 프레임', + 'PROFILE_FRAME') AS new_items +WHERE (SELECT COUNT(*) FROM item) < 3; + +INSERT INTO users (`point`, nickname, information, identifier, tags, provider_info, `role`) +SELECT 0, + 'Guest', + '자기 소개입니다.', + 'Guest', + 'Java,Spring', + 'GITHUB', + 'USER' +WHERE NOT EXISTS (SELECT 1 + FROM users + WHERE identifier = 'Guest'); diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 00000000..7ccb7f4d Binary files /dev/null and b/src/main/resources/static/favicon.ico differ diff --git a/src/test/java/com/genius/gitget/GitgetApplicationTests.java b/src/test/java/com/genius/gitget/GitgetApplicationTests.java new file mode 100644 index 00000000..77159c82 --- /dev/null +++ b/src/test/java/com/genius/gitget/GitgetApplicationTests.java @@ -0,0 +1,21 @@ +package com.genius.gitget; + +import com.genius.gitget.challenge.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; + + +@SpringBootTest +@Sql({"/data.sql"}) +class GitgetApplicationTests { + + @Autowired + private UserRepository userRepository; + + @Test + void test() { + } + +} diff --git a/src/test/java/com/genius/gitget/challenge/certification/controller/CertificationControllerTest.java b/src/test/java/com/genius/gitget/challenge/certification/controller/CertificationControllerTest.java new file mode 100644 index 00000000..5ab111a9 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/controller/CertificationControllerTest.java @@ -0,0 +1,139 @@ +package com.genius.gitget.challenge.certification.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.genius.gitget.challenge.certification.facade.CertificationFacade; +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@Slf4j +@Transactional +@SpringBootTest +class CertificationControllerTest { + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + @Autowired + CertificationFacade certificationFacade; + @Autowired + GithubFacade githubFacade; + @Autowired + InstanceRepository instanceRepository; + @Autowired + UserRepository userRepository; + + @Value("${github.yeon-personalKey}") + private String githubToken; + + @Value("${github.yeon-githubId}") + private String githubId; + + @Value("${github.yeon-repository}") + private String targetRepo; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + @DisplayName("Github token을 전달받아서 검증하여 데이터베이스에 저장할 수 있다.") + @WithMockCustomUser + public void should_saveToken_when_tokenValid() throws Exception { + //given + String requestBody = "{\"githubToken\": \"" + githubToken + "\"}"; + + //when + + //then + mockMvc.perform(post("/api/certification/register/token") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().is2xxSuccessful()); + } + + @Test + @DisplayName("가입하지 않은 사용자인 경우 4xx Client error가 발생한다.") + @WithMockCustomUser(role = Role.NOT_REGISTERED) + public void should_throwException_when_unregisteredUser() throws Exception { + mockMvc.perform(post("/api/certification/register/token")) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("가입한 사용자이나, JWT가 발급되지 않은 경우 4xx client error가 발생한다.") + @WithMockCustomUser(role = Role.NOT_REGISTERED) + public void should_throwException_when_JWTNonExist() throws Exception { + mockMvc.perform(post("/api/certification/register/token") + .headers(tokenTestUtil.createAccessHeaders())) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("회원가입 시 사용한 깃허브 계정과 토큰 계정이 같지 않으면 4xx client error가 발생한다.") + @WithMockCustomUser(identifier = "test") + public void should_throwException_when_accountIncorrect() throws Exception { + //given + String requestBody = "{\"githubToken\": \"" + githubToken + "\"}"; + + //when & then + mockMvc.perform(post("/api/certification/register/token") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("Repository 이름을 전달받아서 검증 후, 데이터베이스에 저장할 수 있다.") + @WithMockCustomUser + public void should_saveToken_when_repositoryValid() throws Exception { + //given + Instance savedInstance = getSavedInstance(); + + //when + User user = userRepository.findByIdentifier(githubId).get(); + githubFacade.registerGithubPersonalToken(user, githubToken); + + //then + mockMvc.perform(get("/api/certification/verify/repository?repo=" + targetRepo) + .headers(tokenTestUtil.createAccessHeaders())) + .andExpect(status().is2xxSuccessful()); + } + + private Instance getSavedInstance() { + return instanceRepository.save( + Instance.builder() + .progress(Progress.PREACTIVITY) + .build() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/certification/repository/CertificationRepositoryTest.java b/src/test/java/com/genius/gitget/challenge/certification/repository/CertificationRepositoryTest.java new file mode 100644 index 00000000..ba3b2a33 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/repository/CertificationRepositoryTest.java @@ -0,0 +1,117 @@ +package com.genius.gitget.challenge.certification.repository; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.CERTIFICATED; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import java.time.LocalDate; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class CertificationRepositoryTest { + @Autowired + CertificationRepository certificationRepository; + @Autowired + UserRepository userRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + ParticipantRepository participantRepository; + + @Test + @DisplayName("Certification 객체를 만들어서 저장할 수 있다.") + public void should() { + //given + LocalDate certificatedDate = LocalDate.of(2024, 2, 1); + String certificationLinks = "https://test.com"; + Participant savedParticipant = getSavedParticipant(getSavedUser(), getSavedInstance()); + + //when + Certification savedCertification = getSavedCertification(NOT_YET, certificatedDate, certificationLinks, + savedParticipant); + + //then + assertThat(savedCertification.getCertificationStatus()).isEqualTo(NOT_YET); + assertThat(savedCertification.getCertificatedAt()).isEqualTo(certificatedDate); + assertThat(savedCertification.getCertificationLinks()).isEqualTo(certificationLinks); + } + + @Test + @DisplayName("인증 일자가 특정 기간에 포함된 Certification 객체들을 찾을 수 있다.") + public void should_returnCertifications_byDuration() { + //given + String certificationLinks = "https://test.com"; + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 2, 4); + Participant participant = getSavedParticipant(getSavedUser(), getSavedInstance()); + + //when + getSavedCertification(NOT_YET, startDate, certificationLinks, participant); + getSavedCertification(CERTIFICATED, startDate.plusDays(1), certificationLinks, participant); + getSavedCertification(CERTIFICATED, endDate.minusDays(1), certificationLinks, participant); + getSavedCertification(CERTIFICATED, endDate, certificationLinks, participant); + + List certifications = certificationRepository.findByDuration(startDate, endDate, + participant.getId()); + + //then + assertThat(certifications.size()).isEqualTo(4); + } + + private Certification getSavedCertification(CertificateStatus status, LocalDate certificatedAt, + String certificationLink, Participant participant) { + Certification certification = Certification.builder() + .certificationStatus(status) + .certificatedAt(certificatedAt) + .certificationLinks(certificationLink) + .build(); + certification.setParticipant(participant); + return certificationRepository.save(certification); + } + + private User getSavedUser() { + return userRepository.save( + User.builder() + .providerInfo(ProviderInfo.GITHUB) + .identifier("identifier") + .role(Role.USER) + .build() + ); + } + + private Instance getSavedInstance() { + return instanceRepository.save( + Instance.builder() + .progress(Progress.ACTIVITY) + .build() + ); + } + + private Participant getSavedParticipant(User user, Instance instance) { + Participant participant = Participant.builder() + .joinStatus(JoinStatus.YES) + .build(); + participant.setUserAndInstance(user, instance); + return participantRepository.save(participant); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/certification/service/CertificationFacadeTest.java b/src/test/java/com/genius/gitget/challenge/certification/service/CertificationFacadeTest.java new file mode 100644 index 00000000..f6a74183 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/service/CertificationFacadeTest.java @@ -0,0 +1,656 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.CERTIFICATED; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.PASSED; +import static com.genius.gitget.challenge.instance.domain.Progress.ACTIVITY; +import static com.genius.gitget.challenge.instance.domain.Progress.DONE; +import static com.genius.gitget.challenge.participant.domain.JoinResult.SUCCESS; +import static com.genius.gitget.challenge.user.domain.Role.USER; +import static com.genius.gitget.global.util.exception.ErrorCode.ALREADY_PASSED_CERTIFICATION; +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_USE_PASS_ITEM; +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_ACTIVITY_INSTANCE; +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_CERTIFICATE_PERIOD; +import static com.genius.gitget.store.item.domain.ItemCategory.CERTIFICATION_PASSER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.dto.CertificationInformation; +import com.genius.gitget.challenge.certification.dto.CertificationRequest; +import com.genius.gitget.challenge.certification.dto.CertificationResponse; +import com.genius.gitget.challenge.certification.dto.TotalResponse; +import com.genius.gitget.challenge.certification.dto.WeekResponse; +import com.genius.gitget.challenge.certification.facade.CertificationFacade; +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.certification.repository.CertificationRepository; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.repository.OrdersRepository; +import com.genius.gitget.util.certification.CertificationFactory; +import com.genius.gitget.util.instance.InstanceFactory; +import com.genius.gitget.util.participant.ParticipantFactory; +import com.genius.gitget.util.store.StoreFactory; +import com.genius.gitget.util.user.UserFactory; +import java.time.LocalDate; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +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.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +@ActiveProfiles({"github"}) +class CertificationFacadeTest { + @Autowired + private CertificationFacade certificationFacade; + @Autowired + private GithubFacade githubFacade; + @Autowired + private UserRepository userRepository; + @Autowired + private InstanceRepository instanceRepository; + @Autowired + private ParticipantRepository participantRepository; + @Autowired + private CertificationRepository certificationRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private OrdersRepository ordersRepository; + + @Value("${github.yeon-personalKey}") + private String githubToken; + + @Value("${github.yeon-githubId}") + private String githubId; + + @Value("${github.yeon-repository}") + private String targetRepo; + + private LocalDate currentDate; + private User user; + private Instance instance; + private Participant participant; + + @BeforeEach + void setup() { + user = userRepository.save(UserFactory.createByInfo(githubId, USER)); + githubFacade.registerGithubPersonalToken(user, githubToken); + } + + @Nested + @DisplayName("한 주 간의 인증 내역 조회 시") + class context_inquiry_week_certifications { + @Nested + @DisplayName("인스턴스가 아직 시작하지 않았고, 본인의 정보 조회 시") + class describe_instance_preActivity_inquiry_mine { + @BeforeEach + void setup() { + currentDate = LocalDate.now(); + instance = instanceRepository.save(InstanceFactory.createPreActivity(10)); + participant = participantRepository.save(ParticipantFactory.createPreActivity(user, instance)); + } + + @Test + @DisplayName("반환한 데이터의 개수가 0개여야 한다.") + public void it_return_nothing() { + WeekResponse weekResponses = certificationFacade.getMyWeekCertifications(participant.getId(), + currentDate); + + assertThat(weekResponses.certifications().size()).isEqualTo(0); + } + } + + @Nested + @DisplayName("인스턴스가 진행 중이고, 본인의 정보를 조회할 때") + class describe_instance_activity_inquiry_mine { + @BeforeEach + void setup() { + currentDate = LocalDate.of(2024, 8, 13); + + instance = instanceRepository.save(InstanceFactory.createByInfo(currentDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + } + + @Test + @DisplayName("챌린지의 시작일자가 월요일이 아니고 첫째주일 때, 시작일부터 현재 일자까지의 인증 내역을 반환해야 한다.") + public void it_return_current_certifications() { + int passedDays = 3; + + WeekResponse weekResponses = certificationFacade.getMyWeekCertifications(participant.getId(), + currentDate.plusDays(passedDays)); + + assertThat(weekResponses.certifications().size()).isEqualTo(passedDays + 1); + } + } + + @Nested + @DisplayName("다른 사람들의 한 주간 인증 내역 조회 시") + class describe_inquiry_others { + User other; + + @BeforeEach + void setup() { + other = userRepository.save(UserFactory.createByInfo("identifier2", USER)); + instance = instanceRepository.save(InstanceFactory.createActivity(10)); + participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + participantRepository.save(ParticipantFactory.createProcessing(other, instance)); + } + + + @Test + @DisplayName("본인의 값을 제외하고 반환받아야 한다.") + public void it_return_except_mine() { + currentDate = LocalDate.now(); + + Slice weekResponses = certificationFacade.getOthersWeekCertifications( + user.getId(), instance.getId(), currentDate, + PageRequest.of(0, 10)); + + assertThat(weekResponses.getContent().size()).isEqualTo(1); + } + } + } + + @Nested + @DisplayName("인증 내역 전체 조회 시") + class context_inquiry_whole_certification { + @Nested + @DisplayName("인스턴스의 상태가 PREACTIVITY일 때") + class describe_instance_is_preActivity { + @BeforeEach + void setup() { + currentDate = LocalDate.now(); + instance = instanceRepository.save(InstanceFactory.createPreActivity(10)); + participant = participantRepository.save(ParticipantFactory.createPreActivity(user, instance)); + } + + @Test + @DisplayName("반환하는 데이터의 개수는 0개여야 한다.") + public void it_return_nothing() { + TotalResponse totalResponse = certificationFacade.getTotalCertification(participant.getId(), + currentDate); + + assertThat(totalResponse.certifications().size()).isEqualTo(0); + } + } + + @Nested + @DisplayName("인스턴스의 상태가 ACTIVITY일 떄") + class describe_instance_is_ACTIVITY { + @BeforeEach + void setup() { + LocalDate startedDate = LocalDate.of(2024, 8, 5); + currentDate = LocalDate.of(2024, 8, 13); + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + } + + @Test + @DisplayName("반환하는 데이터의 개수는 시작일자부터 현재일자까지의 일차와 같아야 한다.") + public void it_returns_data_size_current_attempt() { + TotalResponse totalResponse = certificationFacade.getTotalCertification(participant.getId(), + currentDate); + + assertThat(totalResponse.certifications().size()).isEqualTo(9); + } + } + + @Nested + @DisplayName("인스턴스의 상태가 DONE일 때") + class describe_instance_is_Done { + @BeforeEach + void setup() { + LocalDate startedDate = LocalDate.of(2024, 8, 5); + currentDate = LocalDate.of(2024, 8, 20); + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, DONE)); + participant = participantRepository.save( + ParticipantFactory.createByJoinResult(user, instance, SUCCESS)); + } + + @Test + @DisplayName("반환하는 데이터의 개수는 인스턴스의 전체 일차와 같아야 한다.") + public void it_returns_data_size_total_attempt() { + TotalResponse totalResponse = certificationFacade.getTotalCertification(participant.getId(), + currentDate); + + int totalAttempt = instance.getTotalAttempt(); + + assertThat(totalResponse.totalAttempts()).isEqualTo(totalAttempt); + assertThat(totalResponse.certifications().size()).isEqualTo(totalAttempt); + } + } + } + + @Nested + @DisplayName("인증 갱신 시도 시") + class context_try_update_certification { + LocalDate startedDate; + + @Nested + @DisplayName("인스턴스의 인증 가능 조건 확인 시") + class describe_validate_certification_instance_condition { + @BeforeEach + void setup() { + currentDate = LocalDate.of(2024, 2, 5); + startedDate = currentDate.minusDays(5); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + instance.setInstanceUUID("instanceUUID"); + participant.updateRepository(targetRepo); + } + + @Test + @DisplayName("인스턴스의 상태가 ACTIVITY이고, 인스턴스 진행일 사이라면 예외가 발생하지 않는다.") + public void it_not_throw_exception_when_condition_valid() { + assertThatNoException().isThrownBy(() -> { + certificationFacade.updateCertification(user, + CertificationRequest.of(instance.getId(), currentDate)); + }); + } + + @ParameterizedTest + @DisplayName("인스턴스의 상태가 ACTIVITY가 아니라면, NOT_ACTIVITY_INSTANCE 예외가 발생한다.") + @EnumSource(mode = Mode.INCLUDE, names = {"PREACTIVITY", "DONE"}) + public void it_throws_NOT_ACTIVITY_INSTANCE_exception(Progress progress) { + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, progress)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + participant.updateRepository(targetRepo); + + assertThatThrownBy(() -> certificationFacade.updateCertification(user, + CertificationRequest.of(instance.getId(), currentDate))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(NOT_ACTIVITY_INSTANCE.getMessage()); + } + + @Test + @DisplayName("현재 일자가 인스턴스 진행 일 사이가 아니라면 NOT_CERTIFICATE_PERIOD 예외가 발생한다.") + public void it_throws_NOT_CERTIFICATE_PERIOD_exception() { + currentDate = startedDate.minusDays(1); + + assertThatThrownBy(() -> certificationFacade.updateCertification(user, + CertificationRequest.of(instance.getId(), currentDate))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(NOT_CERTIFICATE_PERIOD.getMessage()); + } + } + + @Nested + @DisplayName("인증에 사용할 PR 확인 시") + class describe_check_pr { + CertificationRequest certificationRequest; + + @Test + @DisplayName("PR의 body가 null이거나 empty하다면 인증 결과가 NOT_YET으로 유지된다.") + public void it_does_not_contain_PR_body_empty() { + currentDate = LocalDate.of(2024, 2, 25); + startedDate = currentDate.minusDays(10); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + instance.setInstanceUUID("instanceUUID"); + participant.updateRepository(targetRepo); + + certificationRequest = CertificationRequest.of(instance.getId(), currentDate); + CertificationResponse certificationResponse = certificationFacade.updateCertification(user, + certificationRequest); + + assertThat(certificationResponse.certificateStatus()).isEqualTo(NOT_YET); + } + + @Test + @DisplayName("PR의 body에 PR Template가 없다면 인증 결과가 NOT_YET 으로 유지된다.") + public void it_does_not_contain_PR_template_not_exist() { + currentDate = LocalDate.of(2024, 3, 12); + startedDate = currentDate.minusDays(10); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + instance.setInstanceUUID("instanceUUID"); + participant.updateRepository(targetRepo); + + certificationRequest = CertificationRequest.of(instance.getId(), currentDate); + CertificationResponse certificationResponse = certificationFacade.updateCertification(user, + certificationRequest); + + assertThat(certificationResponse.certificateStatus()).isEqualTo(NOT_YET); + } + + @Test + @DisplayName("PR 인증 조건에 부합한다면 인증 결과를 CERTIFICATED 로 갱신한다.") + public void it_returns_pr_link_when_pr_valid() { + currentDate = LocalDate.of(2024, 8, 11); + startedDate = currentDate.minusDays(10); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + instance.setInstanceUUID("instanceUUID"); + participant.updateRepository(targetRepo); + + certificationRequest = CertificationRequest.of(instance.getId(), currentDate); + CertificationResponse certificationResponse = certificationFacade.updateCertification(user, + certificationRequest); + + assertThat(certificationResponse.certificateStatus()).isEqualTo(CertificateStatus.CERTIFICATED); + } + } + + @Nested + @DisplayName("인증 객체 확인 시") + class describe_check_certification_object { + Certification certification; + + @BeforeEach + void setup() { + currentDate = LocalDate.of(2024, 8, 11); + startedDate = currentDate.minusDays(10); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + instance.setInstanceUUID("instanceUUID"); + participant.updateRepository(targetRepo); + } + + @Test + @DisplayName("인증 객체의 상태가 PASSED라면 ALREADY_PASSED_CERTIFICATION 예외가 발생한다.") + public void it_throws_ALREADY_PASSED_CERTIFICATION_exception() { + certification = certificationRepository.save( + CertificationFactory.createPassed(participant, currentDate)); + + assertThatThrownBy(() -> certificationFacade.updateCertification(user, + CertificationRequest.of(instance.getId(), currentDate))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ALREADY_PASSED_CERTIFICATION.getMessage()); + } + + @Test + @DisplayName("인증 상태가 NOT_YET이라면 상태가 CERTIFICATED로 갱신된다.") + public void it_update_to_CERTIFICATED() { + certification = certificationRepository.save( + CertificationFactory.createNotYet(participant, currentDate) + ); + + CertificationResponse certificationResponse = certificationFacade.updateCertification(user, + CertificationRequest.of(instance.getId(), currentDate)); + + assertThat(certificationResponse.certificateStatus()).isEqualTo(CertificateStatus.CERTIFICATED); + assertThat(certificationResponse.certificatedAt()).isEqualTo(currentDate); + } + + @Test + @DisplayName("인증 상태가 CERTIFICATED라면 certificationLinks의 내용이 갱신된다.") + public void it_update_certificationLinks() { + certification = certificationRepository.save( + CertificationFactory.createCertificated(participant, currentDate) + ); + + CertificationResponse certificationResponse = certificationFacade.updateCertification(user, + CertificationRequest.of(instance.getId(), currentDate)); + + assertThat(certificationResponse.certificateStatus()).isEqualTo(CertificateStatus.CERTIFICATED); + assertThat(certificationResponse.prLinks()).isNotEmpty(); + assertThat(certificationResponse.prCount()).isNotZero(); + } + } + } + + @Nested + @DisplayName("인증 패스 시도 시") + class context_try_pass_certification { + LocalDate startedDate; + Item item; + Orders orders; + Certification certification; + CertificationRequest certificationRequest; + + @BeforeEach + void setup() { + currentDate = LocalDate.of(2024, 2, 5); + startedDate = currentDate.minusDays(5); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + instance.setInstanceUUID("instanceUUID"); + participant.updateRepository(targetRepo); + + item = itemRepository.save(StoreFactory.createItem(CERTIFICATION_PASSER)); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, CERTIFICATION_PASSER, 3)); + + certificationRequest = CertificationRequest.of(instance.getId(), currentDate); + } + + @Nested + @DisplayName("인스턴스의 인증 가능 조건 확인 시") + class describe_validate_instance_certification_condition { + @Test + @DisplayName("인스턴스의 상태가 ACTIVITY이고, 진행일에 해당한다면 인증 패스 처리가 된다.") + public void it_pass_certification_when_condition_valid() { + instance = instanceRepository.save(InstanceFactory.createByInfo(currentDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + participant.updateRepository(targetRepo); + + assertThatNoException().isThrownBy(() -> { + certificationFacade.passCertification(user.getId(), certificationRequest); + }); + } + + @ParameterizedTest + @DisplayName("인스턴스의 상태가 ACTIVITY가 아니라면, NOT_ACTIVITY_INSTANCE 예외가 발생한다.") + @EnumSource(mode = Mode.INCLUDE, names = {"PREACTIVITY", "DONE"}) + public void it_throws_NOT_ACTIVITY_INSTANCE_exception(Progress progress) { + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, progress)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + participant.updateRepository(targetRepo); + + assertThatThrownBy(() -> certificationFacade.passCertification(user.getId(), + CertificationRequest.of(instance.getId(), currentDate))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(NOT_ACTIVITY_INSTANCE.getMessage()); + } + + @Test + @DisplayName("현재 일자가 인스턴스 진행일 사이가 아니라면, NOT_CERTIFICATE_PERIOD 예외가 발생한다.") + public void it_throws_NOT_CERTIFICATE_PERIOD_exception() { + currentDate = startedDate.minusDays(1); + + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + participant.updateRepository(targetRepo); + + assertThatThrownBy(() -> certificationFacade.passCertification(user.getId(), + CertificationRequest.of(instance.getId(), currentDate))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(NOT_CERTIFICATE_PERIOD.getMessage()); + } + } + + @Nested + @DisplayName("인증 객체의 인증 가능 조건 확인 시") + class describe_validate_certification_condition { + @ParameterizedTest + @DisplayName("인증의 상태가 NOT_YET이 아니라면 CAN_NOT_USE_PASS_ITEM 예외가 발생한다.") + @EnumSource(mode = Mode.INCLUDE, names = {"CERTIFICATED", "PASSED"}) + public void it_throws_exception_when_certificateStatus_not_NOT_YET(CertificateStatus status) { + certification = certificationRepository.save( + CertificationFactory.create(status, currentDate, participant)); + + assertThatThrownBy(() -> certificationFacade.passCertification(user.getId(), certificationRequest)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_USE_PASS_ITEM.getMessage()); + } + + @Test + @DisplayName("인증의 상태가 NOT_YET이라면 예외가 발생하지 않는다.") + public void it_not_throws_exception_when_certificateStatus_is_NOT_YET() { + certification = certificationRepository.save( + CertificationFactory.create(NOT_YET, currentDate, participant) + ); + + assertThatNoException().isThrownBy(() -> { + certificationFacade.passCertification(user.getId(), certificationRequest); + }); + } + } + + @Nested + @DisplayName("인증 객체의 존재 여부 확인 시") + class describe_check_certification_exist { + @Test + @DisplayName("인증 객체가 존재하지 않았다면 인증 객체를 새로 저장한다.") + public void it_save_new_object_when_not_exist() { + Optional beforePassed = certificationRepository.findByDate(currentDate, + participant.getId()); + + certificationFacade.passCertification(user.getId(), certificationRequest); + Optional afterPassed = certificationRepository.findByDate(currentDate, + participant.getId()); + + assertThat(beforePassed).isNotPresent(); + assertThat(afterPassed).isPresent(); + } + + @Test + @DisplayName("인증 객체가 존재했다면 PASSED로 상태를 업데이트한다.") + public void it_update_certificateStatus_to_PASSED() { + certification = certificationRepository.save( + CertificationFactory.createNotYet(participant, currentDate) + ); + + certificationFacade.passCertification(user.getId(), certificationRequest); + Optional afterPassed = certificationRepository.findByDate(currentDate, + participant.getId()); + + assertThat(afterPassed).isPresent(); + assertThat(afterPassed.get().getCertificationStatus()).isEqualTo(PASSED); + } + } + } + + @Nested + @DisplayName("인증 관련 정보 조회 시") + class context_inquiry_certification_information { + LocalDate startedDate; + + @Nested + @DisplayName("인스턴스의 상태가 모두 PREACTIVITY라면") + class describe_instance_is_all_preActivity { + @BeforeEach + void setup() { + currentDate = LocalDate.now(); + instance = instanceRepository.save(InstanceFactory.createPreActivity(10)); + participant = participantRepository.save(ParticipantFactory.createPreActivity(user, instance)); + } + + @Test + @DisplayName("성공/실패의 값은 모두 0이고, 남은일자는 전체 회차여야 한다.") + public void it_returns_correct_values() { + CertificationInformation information = certificationFacade.getCertificationInformation(instance, + participant, currentDate); + + assertThat(information.totalAttempt()).isEqualTo(instance.getTotalAttempt()); + assertThat(information.currentAttempt()).isZero(); + assertThat(information.successCount()).isZero(); + assertThat(information.failureCount()).isZero(); + assertThat(information.remainCount()).isEqualTo(instance.getTotalAttempt()); + } + } + + @Nested + @DisplayName("인스턴스의 상태가 모두 ACTIVITY라면") + class describe_instance_is_all_activity { + @BeforeEach + void setup() { + startedDate = LocalDate.of(2024, 8, 10); + currentDate = LocalDate.of(2024, 8, 15); + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, ACTIVITY)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + + certificationRepository.save(CertificationFactory.create(NOT_YET, startedDate, participant)); + certificationRepository.save( + CertificationFactory.create(CERTIFICATED, startedDate.plusDays(1), participant)); + certificationRepository.save( + CertificationFactory.create(CERTIFICATED, startedDate.plusDays(2), participant)); + certificationRepository.save( + CertificationFactory.create(PASSED, startedDate.plusDays(3), participant)); + certificationRepository.save( + CertificationFactory.create(NOT_YET, startedDate.plusDays(4), participant)); + } + + @Test + @DisplayName("성공/실패/남을 일자가 올바르게 나와야 한다.") + public void it_returns_correct_values() { + CertificationInformation information = certificationFacade.getCertificationInformation(instance, + participant, currentDate); + + assertThat(information.totalAttempt()).isEqualTo(instance.getTotalAttempt()); + assertThat(information.successCount()).isEqualTo(3); + assertThat(information.failureCount()).isEqualTo(3); + assertThat(information.currentAttempt()).isEqualTo(6); + assertThat(information.remainCount()).isEqualTo(instance.getTotalAttempt() - 6); + } + } + + @Nested + @DisplayName("인스턴스의 상태가 모두 DONE이라면") + class describe_instance_is_all_DONE { + @BeforeEach + void setup() { + startedDate = LocalDate.of(2024, 8, 10); + currentDate = LocalDate.of(2024, 8, 15); + instance = instanceRepository.save(InstanceFactory.createByInfo(startedDate, DONE)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + + certificationRepository.save(CertificationFactory.create(NOT_YET, startedDate, participant)); + certificationRepository.save( + CertificationFactory.create(CERTIFICATED, startedDate.plusDays(1), participant)); + certificationRepository.save( + CertificationFactory.create(CERTIFICATED, startedDate.plusDays(2), participant)); + certificationRepository.save( + CertificationFactory.create(PASSED, startedDate.plusDays(3), participant)); + certificationRepository.save( + CertificationFactory.create(NOT_YET, startedDate.plusDays(4), participant)); + } + + @Test + @DisplayName("성공/실패/남은일자가 올바르게 나와야 한다.") + public void it_returns_correct_values() { + CertificationInformation information = certificationFacade.getCertificationInformation(instance, + participant, currentDate); + + int totalAttempt = instance.getTotalAttempt(); + + assertThat(information.totalAttempt()).isEqualTo(instance.getTotalAttempt()); + assertThat(information.successCount()).isEqualTo(3); + assertThat(information.failureCount()).isEqualTo(totalAttempt - 3); + assertThat(information.currentAttempt()).isEqualTo(totalAttempt); + assertThat(information.remainCount()).isEqualTo(0); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/certification/service/CertificationServiceTest.java b/src/test/java/com/genius/gitget/challenge/certification/service/CertificationServiceTest.java new file mode 100644 index 00000000..e92cbfc5 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/service/CertificationServiceTest.java @@ -0,0 +1,187 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.CERTIFICATED; +import static com.genius.gitget.challenge.certification.domain.CertificateStatus.NOT_YET; +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class CertificationServiceTest { + @Autowired + private CertificationService certificationService; + @Autowired + private ParticipantRepository participantRepository; + @Autowired + private InstanceRepository instanceRepository; + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("DB에서 특정 기간 내의 인증 객체 리스트들을 받아올 수 있다.") + public void should_returnList_when_passDuration() { + //given + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 2, 5); + User user = getSavedUser(); + Instance instance = getSavedInstance(); + Participant participant = getSavedParticipant(user, instance); + + getSavedCertification(startDate, CERTIFICATED, "link1", participant); + getSavedCertification(startDate.plusDays(1), CERTIFICATED, "link1", participant); + getSavedCertification(endDate.minusDays(1), NOT_YET, null, participant); + getSavedCertification(endDate.minusDays(2), CERTIFICATED, "link1", participant); + + //when + List certifications = certificationService.findByDuration(startDate, endDate, + participant.getId()); + + //then + assertThat(certifications.size()).isEqualTo(4); + } + + @Test + @DisplayName("특정 일자에 저장된 인증 객체를 받아올 수 있다.") + public void should_getCertification_when_passDate() { + //given + LocalDate targetDate = LocalDate.of(2024, 2, 1); + User user = getSavedUser(); + Instance instance = getSavedInstance(); + Participant participant = getSavedParticipant(user, instance); + + //when + getSavedCertification(targetDate, CERTIFICATED, "link1", participant); + Optional byDate = certificationService.findByDate(targetDate, participant.getId()); + + //then + assertThat(byDate).isPresent(); + } + + @Test + @DisplayName("특정 기간 이내에 특정 인증 상태인 인증 객체의 개수를 받아올 수 있다.") + public void should_count_when_passStatus() { + //given + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 2, 5); + User user = getSavedUser(); + Instance instance = getSavedInstance(); + Participant participant = getSavedParticipant(user, instance); + + getSavedCertification(startDate, CERTIFICATED, "link1", participant); + getSavedCertification(startDate.plusDays(1), CERTIFICATED, "link1", participant); + getSavedCertification(endDate.minusDays(1), NOT_YET, null, participant); + getSavedCertification(endDate.minusDays(2), CERTIFICATED, "link1", participant); + + //when + int certificated = certificationService.countByStatus(participant.getId(), CERTIFICATED, + endDate); + + //then + assertThat(certificated).isEqualTo(3); + } + + @Test + @DisplayName("사용자가 인증을 생성/갱신할 수 있다.") + public void should_renewCertification() { + //given + User user = getSavedUser(); + Instance instance = getSavedInstance(); + Participant participant = getSavedParticipant(user, instance); + LocalDate targetDate = LocalDate.of(2024, 2, 1); + List pullRequests = List.of("pr link1", "pr link2"); + + //when + Certification certification = certificationService.createCertificated(participant, targetDate, + pullRequests); + + //then + assertThat(certification.getCertificatedAt()).isEqualTo(targetDate); + } + + @Test + @DisplayName("인증과 관련된 정보를 전달했을 때, 객체의 정보를 업데이트할 수 있다.") + public void should_update_when_passInfo() { + //given + User user = getSavedUser(); + Instance instance = getSavedInstance(); + Participant participant = getSavedParticipant(user, instance); + LocalDate targetDate = LocalDate.of(2024, 2, 1); + Certification certification = getSavedCertification(targetDate, NOT_YET, "", participant); + List pullRequests = List.of("pr link1", "pr link2"); + + //when + Certification updatedCertification = certificationService.update(certification, targetDate, pullRequests); + + //then + assertThat(updatedCertification.getId()).isEqualTo(certification.getId()); + assertThat(updatedCertification.getCertificatedAt()).isEqualTo(targetDate); + assertThat(updatedCertification.getCertificationStatus()).isEqualTo(CERTIFICATED); + assertThat(updatedCertification.getCertificationLinks()).isEqualTo("pr link1,pr link2,"); + } + + private Certification getSavedCertification(LocalDate certificatedAt, CertificateStatus status, + String link, Participant participant) { + Certification certification = certificationService.save( + Certification.builder() + .certificatedAt(certificatedAt) + .certificationStatus(status) + .certificationLinks(link) + .build() + ); + certification.setParticipant(participant); + return certification; + } + + private Participant getSavedParticipant(User user, Instance instance) { + Participant participant = participantRepository.save( + Participant.createDefaultParticipant("repo") + ); + participant.setUserAndInstance(user, instance); + return participant; + } + + private Instance getSavedInstance() { + return instanceRepository.save( + Instance.builder() + .startedDate(LocalDateTime.of(2024, 2, 1, 0, 0)) + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .build() + ); + } + + private User getSavedUser() { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("githubId") + .information("information") + .tags("BE,FE") + .build() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/certification/service/GithubFacadeTest.java b/src/test/java/com/genius/gitget/challenge/certification/service/GithubFacadeTest.java new file mode 100644 index 00000000..a1e8fbf1 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/service/GithubFacadeTest.java @@ -0,0 +1,229 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_REPOSITORY_INCORRECT; +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_TOKEN_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.certification.dto.github.PullRequestResponse; +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.certification.util.EncryptUtil; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.util.user.UserFactory; +import java.time.LocalDate; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +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 org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +public class GithubFacadeTest { + @Autowired + private EncryptUtil encryptUtil; + @Autowired + private GithubFacade githubFacade; + @Autowired + private UserRepository userRepository; + + @Value("${github.yeon-personalKey}") + private String githubToken; + @Value("${github.yeon-githubId}") + private String githubId; + @Value("${github.yeon-repository}") + private String targetRepo; + + private User user; + + @BeforeEach + void setup() { + user = userRepository.save(UserFactory.createByInfo(githubId, Role.USER)); + } + + @Nested + @DisplayName("Github Token 등록 시") + class context_register_github_token { + @Nested + @DisplayName("Github 연결에 이상이 없다면") + class describe_connection_valid { + @Test + @DisplayName("Github token을 암호화하여 User 엔티티에 저장한다.") + public void it_save_token_to_user_entity() { + String encrypted = encryptUtil.encrypt(githubToken); + + githubFacade.registerGithubPersonalToken(user, githubToken); + + assertThat(user.getGithubToken()).isEqualTo(encrypted); + } + } + + @Nested + @DisplayName("깃허브 계정 정보와 identifier 비교 시") + class describe_mismatch_id { + @Test + @DisplayName("사용자의 identifier와 일치하지 않으면 GITHUB_ID_INCORRECT 예외가 발생한다.") + public void it_throws_GITHUB_ID_INCORRECT_exception() { + user = userRepository.save(UserFactory.createByInfo("incorrectID", Role.USER)); + + assertThatThrownBy(() -> githubFacade.registerGithubPersonalToken(user, githubToken)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.GITHUB_ID_INCORRECT.getMessage()); + } + } + } + + @Nested + @DisplayName("Repository 유효성 확인 시") + class context_register_repository { + @Nested + @DisplayName("사용자로부터 Github token을 불러올 때") + class describe_get_github_token { + @Test + @DisplayName("사용자에게 Github token이 저장되어 있지 않다면 GITHUB_TOKEN_NOT_FOUND 예외가 발생한다.") + public void it_throws_GITHUB_TOKEN_NOT_FOUND_exception() { + assertThatThrownBy(() -> githubFacade.verifyRepository(user, targetRepo)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(GITHUB_TOKEN_NOT_FOUND.getMessage()); + } + } + + @Nested + @DisplayName("repository의 이름을 전달했을 때") + class describe_pass_repository_name { + @BeforeEach + void setup() { + user.updateGithubPersonalToken(encryptUtil.encrypt(githubToken)); + } + + @Test + @DisplayName("해당 깃허브 계정에 repository가 존재한다면 예외가 발생하지 않는다.") + public void it_not_throw_exception() { + assertThatNoException().isThrownBy(() -> { + githubFacade.verifyRepository(user, targetRepo); + }); + } + + @Test + @DisplayName("해당 깃허브 계정에 Repository가 존재하지 않는다면 GITHUB_REPOSITORY_INCORRECT 예외가 발생한다.") + public void it_throw_GITHUB_REPOSITORY_INCORRECT_exception() { + String fakeRepoName = "Fake"; + + assertThatThrownBy(() -> githubFacade.verifyRepository(user, fakeRepoName)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(GITHUB_REPOSITORY_INCORRECT.getMessage()); + } + } + } + + @Nested + @DisplayName("특정 일자의 PR 내역 확인 시") + class context_check_PR { + LocalDate targetDate; + + @Nested + @DisplayName("조건 확인 시") + class describe_validate_condition { + @Test + @DisplayName("사용자의 Github token이 저장되어 있지 않을 때 GITHUB_TOKEN_NOT_FOUND 예외가 발생한다.") + public void it_throws_GITHUB_TOKEN_NOT_FOUND_exception() { + targetDate = LocalDate.of(2024, 1, 4); + assertThatThrownBy(() -> githubFacade.getPullRequestListByDate(user, targetRepo, targetDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(GITHUB_TOKEN_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("repository 이름이 유효하지 않을 때 예외가 발생한다.") + public void it_throws_GITHUB_REPOSITORY_INCORRECT_exception() { + String fakeRepo = "fake Repo"; + targetDate = LocalDate.of(2024, 2, 5); + user.updateGithubPersonalToken(encryptUtil.encrypt(githubToken)); + + assertThatThrownBy(() -> githubFacade.getPullRequestListByDate(user, fakeRepo, targetDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(GITHUB_REPOSITORY_INCORRECT.getMessage()); + } + } + + @Nested + @DisplayName("PR을 확인할 수 있는 유효한 조건일 때") + class describe_valid_condition { + @BeforeEach + void setup() { + user.updateGithubPersonalToken(encryptUtil.encrypt(githubToken)); + } + + @Test + @DisplayName("특정 일자에 PR이 존재하지 않는다면 빈 리스트를 반환한다.") + public void it_returns_emptyList_when_pr_not_exist() { + LocalDate targetDate = LocalDate.of(2024, 1, 4); + + List pullRequestResponses = githubFacade.getPullRequestListByDate( + user, targetRepo, targetDate); + + assertThat(pullRequestResponses.size()).isEqualTo(0); + } + + @Test + @DisplayName("특정 일자에 PR이 존재한다면 목록을 불러 올 수 있다.") + public void it_returns_pr_list() { + LocalDate targetDate = LocalDate.of(2024, 2, 5); + + List pullRequestResponses = githubFacade.getPullRequestListByDate( + user, targetRepo, targetDate); + + assertThat(pullRequestResponses.size()).isEqualTo(1); + } + } + } + + @Nested + @DisplayName("특정 레포지토리에 PR이 존재하는지 확인 시") + class context_verify_pr { + LocalDate targetDate; + + @BeforeEach + void setup() { + targetDate = LocalDate.of(2024, 3, 5); + user.updateGithubPersonalToken(encryptUtil.encrypt(githubToken)); + } + + @Nested + @DisplayName("PR 확인 시") + class describe_check_pr { + @Test + @DisplayName("존재하지 않는다면 GITHUB_PR_NOT_FOUND 예외가 발생한다.") + public void it_throws_GITHUB_PR_NOT_FOUND_exception() { + assertThatThrownBy(() -> githubFacade.verifyPullRequest(user, targetRepo, targetDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.GITHUB_PR_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("특정 일자에 PR이 존재한다면 결과를 List로 반환한다.") + public void it_returns_list_if_pr_exist() { + targetDate = LocalDate.of(2024, 2, 5); + + //when + List pullRequestResponses = githubFacade.verifyPullRequest(user, targetRepo, + targetDate); + + //then + assertThat(pullRequestResponses.size()).isNotZero(); + + } + } + } +} diff --git a/src/test/java/com/genius/gitget/challenge/certification/service/GithubServiceTest.java b/src/test/java/com/genius/gitget/challenge/certification/service/GithubServiceTest.java new file mode 100644 index 00000000..2e67c65c --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/service/GithubServiceTest.java @@ -0,0 +1,152 @@ +package com.genius.gitget.challenge.certification.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_ID_INCORRECT; +import static com.genius.gitget.global.util.exception.ErrorCode.GITHUB_REPOSITORY_INCORRECT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.global.util.exception.BusinessException; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles({"github"}) +class GithubServiceTest { + @Autowired + private GithubService githubService; + + @Value("${github.yeon-personalKey}") + private String personalKey; + + @Value("${github.yeon-githubId}") + private String githubId; + + @Value("${github.yeon-repository}") + private String repository; + + @Test + @DisplayName("정상적인 github token을 전달받았을 때, API를 통해 GitHub 객체를 반환받을 수 있다.") + public void should_returnGitHubInstance_when_passValidToken() { + //given + + //when + GitHub gitHub = githubService.getGithubConnection(personalKey); + + //then + assertThat(gitHub).isNotNull(); + } + + @Test + @DisplayName("github token을 전달받았을 때, 사용자가 소셜로그인할 때 사용했던 깃허브 계정 아이디와 일치한다면 연결 성공으로 간주한다.") + public void should_checkConnection_when_passPersonalToken() { + //given + GitHub gitHub = getGitHub(); + + //when + githubService.validateGithubConnection(gitHub, githubId); + } + + @Test + @DisplayName("github token을 전달받았을 때, 소셜로그인 깃허브 계정 아이디와 일치하지 않는다면 예외가 발생한다.") + public void should_throwException_when_idIncorrect() { + //given + GitHub gitHub = getGitHub(); + String githubId = "fake Id"; + + //when & then + assertThatThrownBy(() -> githubService.validateGithubConnection(gitHub, githubId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(GITHUB_ID_INCORRECT.getMessage()); + } + + @Test + @DisplayName("특정 사용자의 특정 repository가 연결됨을 검증할 수 있다.") + public void should_findRepository_when_passToken() throws IOException { + // given + GitHub gitHub = getGitHub(); + + //when & then + githubService.validateGithubRepository(gitHub, githubId + "/" + repository); + } + + @Test + @DisplayName("전달받은 Repository명이 명확하지 않는다면 예외가 발생한다.") + public void should_throwException_when_repositoryNameInvalid() { + //given + GitHub gitHub = getGitHub(); + String repositoryName = "fake repository"; + + //when & then + assertThatThrownBy(() -> githubService.validateGithubRepository(gitHub, repositoryName)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("해당 레포지토리에 있는 PR을 확인할 수 있다.") + public void should_checkPR_when_validRepo() { + //given + GitHub gitHub = getGitHub(); + LocalDate createdAt = LocalDate.of(2024, 2, 5); + + //when + List pullRequest = githubService.getPullRequestByDate(gitHub, repository, createdAt); + + //then + assertThat(pullRequest.size()).isEqualTo(1); + } + + @Test + @DisplayName("특정 레포지토리에 연결이 되지 않으면 예외를 발생한다.") + public void should_throwException_when_repoConnectionInvalid() { + //given + GitHub gitHub = getGitHub(); + String repositoryName = "Fake"; + LocalDate createdAt = LocalDate.of(2024, 2, 5); + + //when & then + assertThatThrownBy(() -> githubService.getPullRequestByDate(gitHub, repositoryName, createdAt)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(GITHUB_REPOSITORY_INCORRECT.getMessage()); + } + + @Test + @DisplayName("사용자가 가지고 있는 레포지토리 리스트들을 반환할 수 있다.") + public void should_returnRepositories() { + //given + GitHub gitHub = getGitHub(); + + //when + List repositoryList = githubService.getRepositoryList(gitHub); + + //then + assertThat(repositoryList.size()).isGreaterThan(0); + } + + @Test + @DisplayName("Pr 인증을 시도 했을 때, KST 기준으로 생성된 PR 리스트를 불러올 수 있다.") + public void should_searchPR_when_tryToCertificate() { + //given + GitHub gitHub = getGitHub(); + LocalDate kstDate = LocalDate.of(2024, 2, 25); + + //when + List pullRequests = githubService.getPullRequestByDate(gitHub, repository, kstDate); + + //then + assertThat(pullRequests.size()).isEqualTo(2); + } + + private GitHub getGitHub() { + return githubService.getGithubConnection(personalKey); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/certification/util/DateUtilTest.java b/src/test/java/com/genius/gitget/challenge/certification/util/DateUtilTest.java new file mode 100644 index 00000000..2a135113 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/util/DateUtilTest.java @@ -0,0 +1,139 @@ +package com.genius.gitget.challenge.certification.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@Slf4j +class DateUtilTest { + + @Test + @DisplayName("시작 날짜와 현재 날짜를 전달했을 때, 오늘의 날짜가 몇 번째 회차인지 구할 수 있다.") + public void should_getAttempt_when_passDate() { + //given + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 3, 4); + + //when + int attempt = DateUtil.getAttemptCount(startDate, endDate); + + //then + assertThat(attempt).isEqualTo(33); + } + + @Test + @DisplayName("첫 주차의 인증 현황을 조회할 때 챌린지의 시작 요일이 월요일이 아니라면, 시작 날짜를 기준으로 계산한다.") + public void should_calculateByStartDate_when_startDateIsNotMonday() { + //given + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 2, 3); + + //when + int weekAttempt = DateUtil.getWeekAttempt(startDate, endDate); + + //then + assertThat(weekAttempt).isEqualTo(3); + } + + @Test + @DisplayName("일반적으로 주차별 인증 현황을 조회할 때, 요일에 따라 계산한다.") + public void should_calculateByDay_when_getListGenerally() { + //given + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 2, 15); + + //when + int weekAttempt = DateUtil.getWeekAttempt(startDate, endDate); + + //then + assertThat(weekAttempt).isEqualTo(4); + } + + @Test + @DisplayName("Date를 전달했을 때 LocalDate로 변환할 수 있다.") + public void should_convertToLocalDate_when_passDate() { + //given + Date date = new Date(1725000000000L); + + //when + LocalDate localDate = DateUtil.convertToKST(date); + + //then + assertThat(localDate).isEqualTo(LocalDate.of(2024, 8, 30)); + } + + @Test + @DisplayName("시작일자과 현재일자를 전달했을 때, 챌린지 시작까지 몇 일 남았는지 구할 수 있다.") + public void should_getRemainDays_when_passStartDate() { + //given + LocalDate startDate = LocalDate.of(2024, 3, 10); + LocalDate targetDate = LocalDate.of(2024, 3, 1); + + //when + int remainDays = DateUtil.getRemainDaysToStart(startDate, targetDate); + + //then + assertThat(remainDays).isEqualTo(9); + } + + @Test + @DisplayName("현재일자가 시작일자보다 더 이후의 날짜일 때, 남은 일수를 0으로 반환한다.") + public void should_returnMinus_when_startDateBeforeThenTargetDate() { + //given + LocalDate targetDate = LocalDate.of(2024, 3, 10); + LocalDate startDate = LocalDate.of(2024, 3, 1); + + //when + int remainDays = DateUtil.getRemainDaysToStart(startDate, targetDate); + + //then + assertThat(remainDays).isEqualTo(0); + } + + @Test + @DisplayName("챌린지 시작 일자가 월요일이 아니고 시작한 그 주일 때, 시작일자를 반환해야 한다.") + public void should_returnStartDate_when_StartDateNotMonDayAndSecondWeek() { + LocalDate challengeStartDate = LocalDate.of(2024, 3, 13); + LocalDate targetDate = LocalDate.of(2024, 3, 15); + + //when + LocalDate weekStartDate = DateUtil.getWeekStartDate(challengeStartDate, targetDate); + + //then + assertThat(weekStartDate).isEqualTo(challengeStartDate); + } + + @Test + @DisplayName("챌린지 시작일자가 월요일이 아니고 그 다움주일 때, 해당 주의 월요일을 반환해야 한다.") + public void should_returnMonday_when_startDateIsNotMonday() { + LocalDate challengeStartDate = LocalDate.of(2024, 3, 10); + LocalDate targetDate = LocalDate.of(2024, 3, 15); + + //when + LocalDate weekStartDate = DateUtil.getWeekStartDate(challengeStartDate, targetDate); + + //then + + assertThat(weekStartDate.getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); + } + + @Test + @DisplayName("챌린지 시작일자에 상관없이 시작한지 두 번째 주 일 때, 해당 주의 월요일을 전달해야한다") + public void should_returnMonday_when_secondWeek() { + //given + LocalDate challengeStartDate = LocalDate.of(2024, 3, 10); + LocalDate targetDate = LocalDate.of(2024, 3, 20); + + //when + LocalDate weekStartDate = DateUtil.getWeekStartDate(challengeStartDate, targetDate); + + //then + assertThat(weekStartDate).isEqualTo(LocalDate.of(2024, 3, 18)); + assertThat(weekStartDate.getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/certification/util/EncryptUtilTest.java b/src/test/java/com/genius/gitget/challenge/certification/util/EncryptUtilTest.java new file mode 100644 index 00000000..166098ff --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/certification/util/EncryptUtilTest.java @@ -0,0 +1,31 @@ +package com.genius.gitget.challenge.certification.util; + +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class EncryptUtilTest { + @Autowired + EncryptUtil encryptUtil; + + @Test + @DisplayName("특정 문자열에 대해서 암호화하고 복호화했을 때, 원래의 값과 일치해야 한다.") + public void should_returnOrigin_when_decrypt() { + //given + String target = "target token"; + + //when + String encrypted = encryptUtil.encrypt(target); + String decrypted = encryptUtil.decrypt(encrypted); + + //then + Assertions.assertThat(decrypted).isEqualTo(target); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/home/controller/InstanceHomeControllerTest.java b/src/test/java/com/genius/gitget/challenge/home/controller/InstanceHomeControllerTest.java new file mode 100644 index 00000000..22f67a3e --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/home/controller/InstanceHomeControllerTest.java @@ -0,0 +1,95 @@ +package com.genius.gitget.challenge.home.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +class InstanceHomeControllerTest { + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceRepository instanceRepository; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + + @Test + @DisplayName("특정 사용자의 관심사 태그를 확인하여, 태그와 일치하는 인스턴스들을 참여 인원 순으로 반환한다.") + @WithMockCustomUser + public void should_returnInstances_when_passUserTags() throws Exception { + //given + getSavedInstance("title1", "BE", 20); + getSavedInstance("title2", "BE", 34); + getSavedInstance("title3", "FE", 10); + getSavedInstance("title4", "AI", 2); + + //when & then + mockMvc.perform(get("/api/challenges/recommend") + .headers(tokenTestUtil.createAccessHeaders())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.numberOfElements").value(3)); + } + + + private Instance getSavedInstance(String title, String tags, int participantCnt) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(100) + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + instance.setTopic(getSavedTopic()); + return instance; + } + + private Topic getSavedTopic() { + return topicRepository.save( + Topic.builder() + .title("title") + .description("description") + .tags("BE") + .pointPerPerson(100) + .build() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/home/service/ChallengeRecommendationServiceTest.java b/src/test/java/com/genius/gitget/challenge/home/service/ChallengeRecommendationServiceTest.java new file mode 100644 index 00000000..7ed985de --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/home/service/ChallengeRecommendationServiceTest.java @@ -0,0 +1,136 @@ +package com.genius.gitget.challenge.home.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.dto.home.HomeInstanceResponse; +import com.genius.gitget.challenge.instance.facade.InstanceHomeFacade; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.instance.service.InstanceRecommendationService; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class InstanceHomeFacadeTest { + @Autowired + InstanceHomeFacade instanceHomeFacade; + @Autowired + InstanceRecommendationService instanceRecommendationService; + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceRepository instanceRepository; + + @Test + @DisplayName("사용자가 설정한 태그와 하나라도 맞는 시작 전인 인스턴스의 수 만큼 반환한다.") + public void should_getSuggestions_when_passUserTags() { + //given + PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Direction.DESC, "participantCount")); + getSavedInstance("title1", "BE,AI", 20); + getSavedInstance("title2", "BE,Spring", 10); + getSavedInstance("title3", "FE,BE", 10); + getSavedInstance("title4", "FE,React", 12); + + User user = User.builder().tags("BE,React").build(); + + //when + Slice recommendations = instanceHomeFacade.recommendInstances(user, + pageRequest); + + //then + assertThat(recommendations.getContent().size()).isEqualTo(4); + assertThat(recommendations.getContent().get(0).title()).isEqualTo("title1"); + assertThat(recommendations.getContent().get(0).participantCnt()).isEqualTo(20); + assertThat(recommendations.getContent().get(0).pointPerPerson()).isEqualTo(100); + + assertThat(recommendations.getContent().get(1).title()).isEqualTo("title2"); + assertThat(recommendations.getContent().get(1).participantCnt()).isEqualTo(10); + assertThat(recommendations.getContent().get(1).pointPerPerson()).isEqualTo(100); + } + + @Test + @DisplayName("조건에 맞는 인스턴스의 개수가 pageSize보다 많다면, hasNext()가 true여야 한다.") + public void should_hasNextIsTrue_when_instanceSizeOverThanPageSize() { + //given + PageRequest pageRequest = PageRequest.of(0, 2, Sort.by(Direction.DESC, "participantCount")); + getSavedInstance("title1", "BE,AI", 20); + getSavedInstance("title2", "BE,Spring", 10); + getSavedInstance("title3", "FE,BE", 10); + getSavedInstance("title4", "FE,React", 12); + + User user = User.builder().tags("BE").build(); + + //when + Slice recommendations = instanceHomeFacade.recommendInstances(user, + pageRequest); + + //then + assertThat(recommendations.getContent().size()).isEqualTo(2); + assertThat(recommendations.getContent().get(0).title()).isEqualTo("title1"); + assertThat(recommendations.getContent().get(1).title()).isEqualTo("title2"); + assertThat(recommendations.hasNext()).isTrue(); + } + + @Test + @DisplayName("조건에 맞는 인스턴스의 개수가 pageSize보다 적다면, hasNext()가 false여야 한다.") + public void should_hasNextIsTrue_when_instanceSizeLessThanPageSize() { + //given + PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Direction.DESC, "participantCount")); + getSavedInstance("title1", "BE,AI", 20); + getSavedInstance("title2", "BE,Spring", 10); + getSavedInstance("title3", "FE,BE", 10); + getSavedInstance("title4", "FE,React", 12); + + User user = User.builder().tags("BE").build(); + + //when + Slice recommendations = instanceHomeFacade.recommendInstances(user, + pageRequest); + + //then + assertThat(recommendations.getContent().size()).isEqualTo(3); + assertThat(recommendations.hasNext()).isFalse(); + } + + private Instance getSavedInstance(String title, String tags, int participantCnt) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(100) + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + instance.setTopic(getSavedTopic()); + return instance; + } + + private Topic getSavedTopic() { + return topicRepository.save( + Topic.builder() + .title("title") + .description("description") + .tags("BE") + .pointPerPerson(100) + .build() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/instance/controller/InstanceControllerTest.java b/src/test/java/com/genius/gitget/challenge/instance/controller/InstanceControllerTest.java new file mode 100644 index 00000000..346ca66a --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/controller/InstanceControllerTest.java @@ -0,0 +1,173 @@ +package com.genius.gitget.challenge.instance.controller; + + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import java.time.LocalDateTime; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +public class InstanceControllerTest { + private static Topic savedTopic1, savedTopic2; + private static Instance savedInstance1, savedInstance2; + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + FilesManager filesManager; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + savedTopic1 = getSavedTopic(); + savedTopic2 = getSavedTopic(); + + savedInstance1 = getSavedInstance("title1", "FE", 50, 1000); + savedInstance2 = getSavedInstance("title2", "BE, CS", 50, 1000); + + savedInstance1.setTopic(savedTopic1); + savedInstance2.setTopic(savedTopic1); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("인스턴스 리스트 조회를 요청하면, 상태코드 200반환과 함께 인스턴스 리스트를 반환한다.") + public void 인스턴스_리스트_조회() throws Exception { + mockMvc.perform(get("/api/admin/instance").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.numberOfElements").value(2)); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("특정 토픽에 대한 리스트 조회를 요청하면, 상태코드 200과 함께 데아터를 반환한다.") + public void 특정_토픽에_대한_리스트_조회_1() throws Exception { + Long id = savedTopic1.getId(); + + mockMvc.perform(get("/api/admin/topic/instances/" + id).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.numberOfElements").value(2)) + .andExpect(jsonPath("$.data.content[0].title").value("title1")) + .andExpect(jsonPath("$.data.content[1].title").value("title2")); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("특정 토픽에 대한 리스트 조회를 요청하면, 상태코드 200과 함께 데아터를 반환한다.") + public void 특정_토픽에_대한_리스트_조회_2() throws Exception { + Long id = savedTopic2.getId(); + + mockMvc.perform(get("/api/admin/topic/instances/" + id).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("data.numberOfElements").value(0)); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("인스턴스 단건 조회를 하면, 상태코드 200과 함께 인스턴스 상세정보를 반환한다.") + public void 인스턴스_단건_조회() throws Exception { + Long topicId = savedTopic1.getId(); + + Long instanceId = savedInstance2.getId(); + + mockMvc.perform(get("/api/admin/instance/" + instanceId).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("data.topicId").value(topicId)) + .andExpect(jsonPath("data.instanceId").value(instanceId)); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("인스턴스 삭제 성공하면, 상태코드 200을 반환한다.") + public void 인스턴스_삭제_성공() throws Exception { + Long instanceId = savedInstance1.getId(); + + mockMvc.perform(delete("/api/admin/instance/" + instanceId).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.numberOfElements").doesNotExist()); + + Assertions.assertThat(instanceRepository.findById(instanceId)).isEmpty(); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("인스턴스 삭제 실패하면, 상태코드 4xx을 반환한다.") + public void 인스턴스_삭제_성공_실패() throws Exception { + Long instanceId = savedInstance1.getId(); + + mockMvc.perform(delete("/api/admin/instance/" + instanceId + 1).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + + private Topic getSavedTopic() { + Topic topic = topicRepository.save( + Topic.builder() + .title("title") + .notice("notice") + .description("description") + .tags("BE") + .pointPerPerson(100) + .build() + ); + return topic; + } + + private Instance getSavedInstance(String title, String tags, int participantCnt, int pointPerPerson) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(pointPerPerson) + .certificationMethod("인증 방법") + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } +} diff --git a/src/test/java/com/genius/gitget/challenge/instance/repository/InstanceRepositoryTest.java b/src/test/java/com/genius/gitget/challenge/instance/repository/InstanceRepositoryTest.java new file mode 100644 index 00000000..5f387808 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/repository/InstanceRepositoryTest.java @@ -0,0 +1,253 @@ +package com.genius.gitget.challenge.instance.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.global.util.exception.BusinessException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class InstanceRepositoryTest { + @Autowired + InstanceRepository instanceRepository; + + @Test + public void 인스턴스_생성() throws Exception { + //given + Instance instance = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + String uuid = UUID.randomUUID().toString(); + uuid = uuid.replaceAll("-", "").substring(0, 16); + instance.setInstanceUUID(uuid); + //when + Instance savedInstance = instanceRepository.save(instance); + + //then + Assertions.assertThat(savedInstance.getTitle()).isEqualTo("1일 1알고리즘"); + } + + @Test + public void 인스턴스_uuid를_수정할_수_없다() { + //given + Instance instance = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + String uuid = UUID.randomUUID().toString(); + uuid = uuid.replaceAll("-", "").substring(0, 16); + instance.setInstanceUUID(uuid); + //when + Instance savedInstance = instanceRepository.save(instance); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessException.class, () -> + savedInstance.setInstanceUUID(UUID.randomUUID().toString())); + } + + + @Test + public void 인스턴스_수정() throws Exception { + //given + Instance instance = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + //when + Instance savedInstance = instanceRepository.save(instance); + savedInstance.updateInstance("수정되었습니다.", "수정된 유의사항", 10000, LocalDateTime.now(), + LocalDateTime.now().plusDays(5), "수정된 인증 방식"); + + //then + Assertions.assertThat(instance.getDescription()).isEqualTo(savedInstance.getDescription()); + } + + @Test + public void 인스턴스_조회() throws Exception { + //given + Instance instance = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + //when + Instance savedInstance = instanceRepository.save(instance); + + //then + Assertions.assertThat(savedInstance.getTitle()).isEqualTo("1일 1알고리즘"); + } + + @Test + public void 인스턴스_리스트_조회() throws Exception { + //given + Instance instance1 = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + Instance instance2 = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + //when + instanceRepository.save(instance1); + instanceRepository.save(instance2); + + Page instances = instanceRepository.findAllById(PageRequest.of(0, 5, Sort.Direction.DESC, "id")); + + for (Instance instance : instances) { + if (instance != null) { + System.out.println("instance = " + instance.getId() + " " + instance.getTitle()); + } + } + + //then + Assertions.assertThat(instances.getTotalElements()).isEqualTo(2); + } + + + @Test + @DisplayName("인스턴스들 중, 사용자의 태그와 하나라도 겹친다면 추천 챌린지 결과로 반환받아야 한다.") + public void should_returnInstances_containsUserTags() { + //given + String userTag = "BE"; + + //when + getSavedInstance("title1", "BE,AI", 10); + getSavedInstance("title2", "FE,BE", 3); + getSavedInstance("title3", "FE", 20); + List recommendations = instanceRepository.findRecommendations(userTag, Progress.PREACTIVITY); + + //then + assertThat(recommendations.size()).isEqualTo(2); + assertThat(recommendations.get(0).getTitle()).isEqualTo("title1"); + assertThat(recommendations.get(0).getTags()).isEqualTo("BE,AI"); + assertThat(recommendations.get(0).getParticipantCount()).isEqualTo(10); + + assertThat(recommendations.get(1).getTitle()).isEqualTo("title2"); + assertThat(recommendations.get(1).getTags()).isEqualTo("FE,BE"); + assertThat(recommendations.get(1).getParticipantCount()).isEqualTo(3); + } + + @Test + @DisplayName("인스턴스들 중, 시작 일자가 늦은 순서대로 인스턴스들을 정렬하여 반환받을 수 있다.") + public void should_returnInstances_orderByStartedDate() { + //given + PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Direction.DESC, "startedDate")); + + //when + getSavedInstance("title1", "BE", 10); + getSavedInstance("title2", "BE", 3); + getSavedInstance("title3", "BE", 20); + Slice instances = instanceRepository.findPagesByProgress(Progress.PREACTIVITY, pageRequest); + + //then + assertThat(instances.getContent().size()).isEqualTo(3); + assertThat(instances.getContent().get(0).getTitle()).isEqualTo("title3"); + assertThat(instances.getContent().get(0).getTags()).isEqualTo("BE"); + assertThat(instances.getContent().get(0).getParticipantCount()).isEqualTo(20); + + assertThat(instances.getContent().get(1).getTitle()).isEqualTo("title2"); + assertThat(instances.getContent().get(1).getTags()).isEqualTo("BE"); + assertThat(instances.getContent().get(1).getParticipantCount()).isEqualTo(3); + + assertThat(instances.getContent().get(2).getTitle()).isEqualTo("title1"); + assertThat(instances.getContent().get(2).getTags()).isEqualTo("BE"); + assertThat(instances.getContent().get(2).getParticipantCount()).isEqualTo(10); + } + + @Test + @DisplayName("인스턴스들 중, 참여 인원 수가 많은 순서대로 인스턴스들을 정렬하여 반환받을 수 있다.") + public void should_returnInstances_orderByParticipantCnt() { + //given + PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Direction.DESC, "participantCount")); + + //when + getSavedInstance("title1", "BE", 10); + getSavedInstance("title2", "BE", 3); + getSavedInstance("title3", "BE", 20); + Slice instances = instanceRepository.findPagesByProgress(Progress.PREACTIVITY, pageRequest); + + //then + assertThat(instances.getContent().size()).isEqualTo(3); + assertThat(instances.getContent().get(0).getTitle()).isEqualTo("title3"); + assertThat(instances.getContent().get(0).getTags()).isEqualTo("BE"); + assertThat(instances.getContent().get(0).getParticipantCount()).isEqualTo(20); + + assertThat(instances.getContent().get(1).getTitle()).isEqualTo("title1"); + assertThat(instances.getContent().get(1).getTags()).isEqualTo("BE"); + assertThat(instances.getContent().get(1).getParticipantCount()).isEqualTo(10); + + assertThat(instances.getContent().get(2).getTitle()).isEqualTo("title2"); + assertThat(instances.getContent().get(2).getTags()).isEqualTo("BE"); + assertThat(instances.getContent().get(2).getParticipantCount()).isEqualTo(3); + } + + private Instance getSavedInstance(String title, String tags, int participantCnt) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(100) + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/instance/repository/InstanceSearchRepositoryTest.java b/src/test/java/com/genius/gitget/challenge/instance/repository/InstanceSearchRepositoryTest.java new file mode 100644 index 00000000..b7faa0be --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/repository/InstanceSearchRepositoryTest.java @@ -0,0 +1,208 @@ +package com.genius.gitget.challenge.instance.repository; + +import static com.genius.gitget.challenge.instance.domain.Progress.ACTIVITY; +import static com.genius.gitget.challenge.instance.domain.Progress.DONE; +import static com.genius.gitget.challenge.instance.domain.Progress.PREACTIVITY; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.crud.InstanceCreateRequest; +import com.genius.gitget.challenge.instance.facade.InstanceFacade; +import com.genius.gitget.challenge.instance.service.InstanceSearchService; +import com.genius.gitget.challenge.instance.service.InstanceService; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.file.FileTestUtil; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.io.IOException; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Rollback +@Slf4j +public class InstanceSearchRepositoryTest { + @PersistenceContext + EntityManager em; + @Autowired + SearchRepository searchRepository; + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + InstanceSearchService instanceSearchService; + @Autowired + InstanceService instanceService; + @Autowired + InstanceFacade instanceFacade; + + @Autowired + FileTestUtil fileTestUtil; + + JPAQueryFactory queryFactory; + + @BeforeEach + public void setup() throws IOException { + + queryFactory = new JPAQueryFactory(em); + + Topic topic = Topic.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .build(); + + Instance instanceA = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .notice("유의사항") + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + Instance instanceB = Instance.builder() + .title("1일 3알고리즘") + .description("하루에 세 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(500) + .notice("유의사항") + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(7)) + .build(); + + Instance instanceC = Instance.builder() + .title("2일 3알고리즘") + .description("이것은 끝난 챌린지입니다.") + .tags("BE, FE, CS") + .pointPerPerson(500) + .notice("유의사항") + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(7)) + .build(); + + Topic savedTopic = topicRepository.save(topic); + + instanceFacade.createInstance(createInstance(savedTopic, instanceA, instanceA.getTitle()), + instanceA.getStartedDate().minusDays(3).toLocalDate()); + instanceFacade.createInstance(createInstance(savedTopic, instanceB, instanceB.getTitle()), + instanceA.getStartedDate().minusDays(3).toLocalDate()); + instanceFacade.createInstance(createInstance(savedTopic, instanceB, instanceB.getTitle()), + instanceA.getStartedDate().minusDays(3).toLocalDate()); + instanceFacade.createInstance(createInstance(savedTopic, instanceA, instanceA.getTitle()), + instanceA.getStartedDate().minusDays(3).toLocalDate()); + instanceFacade.createInstance(createInstance(savedTopic, instanceB, "2일 3알고리즘"), + instanceA.getStartedDate().minusDays(3).toLocalDate()); + instanceFacade.createInstance(createInstance(savedTopic, instanceC, instanceC.getTitle()), + instanceA.getStartedDate().minusDays(3).toLocalDate()); + + } + + @Builder + private InstanceCreateRequest createInstance(Topic savedTopic, Instance instance, String title) { + return InstanceCreateRequest.builder() + .topicId(savedTopic.getId()) + .title(title) + .tags(instance.getTags()) + .description(instance.getDescription()) + .notice(instance.getNotice()) + .pointPerPerson(instance.getPointPerPerson()) + .startedAt(instance.getStartedDate()) + .completedAt(instance.getCompletedDate()).build(); + } + + + @Test + public void 검색_조건_없이_테스트() throws Exception { + for (int i = 0; i < 5; i++) { + PageRequest pageRequest = PageRequest.of(i, 2); + Page result = searchRepository.search(null, null, pageRequest); + for (Instance instance : result) { + System.out.println("instanceSearchResponse = " + instance.getId()); + } + System.out.println("========== " + i + 1 + " 번째 끝 ========="); + } + } + + @Test + public void 챌린지_제목으로_검색_테스트() throws Exception { + PageRequest pageRequest = PageRequest.of(0, 10); + Page result = searchRepository.search(null, "2", pageRequest); + int cnt = 0; + for (Instance instance : result) { + if (instance != null) { + cnt++; + } + } + Assertions.assertThat(cnt).isEqualTo(2); + } + + + @Test + public void 챌린지_현황으로_검색_테스트() throws Exception { + PageRequest pageRequest = PageRequest.of(0, 10); + Page result = searchRepository.search(PREACTIVITY, null, pageRequest); + int cnt = 0; + for (Instance instance : result) { + if (instance != null) { + cnt++; + } + } + Assertions.assertThat(cnt).isEqualTo(6); + } + + @Test + public void 챌린지_현황으로_검색_테스트2() throws Exception { + PageRequest pageRequest = PageRequest.of(0, 10); + Page result = searchRepository.search(DONE, null, pageRequest); + int cnt = 0; + for (Instance instance : result) { + if (instance != null) { + cnt++; + } + } + Assertions.assertThat(cnt).isEqualTo(0); + } + + @Test + public void 챌린지_현황으로_검색_테스트3() throws Exception { + PageRequest pageRequest = PageRequest.of(0, 10); + Page result = searchRepository.search(ACTIVITY, null, pageRequest); + int cnt = 0; + for (Instance instance : result) { + if (instance != null) { + cnt++; + } + } + Assertions.assertThat(cnt).isEqualTo(0); + } + + @Test + public void 챌린지_현황과_챌린지_제목으로_검색_테스트() throws Exception { + PageRequest pageRequest = PageRequest.of(0, 10); + Page result = searchRepository.search(PREACTIVITY, "3", pageRequest); + int cnt = 0; + for (Instance instance : result) { + if (instance != null) { + cnt++; + } + } + Assertions.assertThat(cnt).isEqualTo(4); + } + +} diff --git a/src/test/java/com/genius/gitget/challenge/instance/service/InstanceDetailServiceTest.java b/src/test/java/com/genius/gitget/challenge/instance/service/InstanceDetailServiceTest.java new file mode 100644 index 00000000..39cfb550 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/service/InstanceDetailServiceTest.java @@ -0,0 +1,508 @@ +package com.genius.gitget.challenge.instance.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_JOIN_INSTANCE; +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_QUIT_INSTANCE; +import static com.genius.gitget.global.util.exception.ErrorCode.INSTANCE_NOT_FOUND; +import static com.genius.gitget.global.util.exception.ErrorCode.PARTICIPANT_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.dto.detail.InstanceResponse; +import com.genius.gitget.challenge.instance.dto.detail.JoinRequest; +import com.genius.gitget.challenge.instance.dto.detail.JoinResponse; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.facade.LikesFacade; +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.participant.service.ParticipantService; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import java.time.LocalDate; +import java.time.LocalDateTime; +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.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class InstanceDetailServiceTest { + + @Autowired + InstanceDetailFacade instanceDetailFacade; + @Autowired + LikesFacade likesFacade; + @Autowired + ParticipantService participantService; + @Autowired + GithubFacade githubFacade; + @Autowired + UserRepository userRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + ParticipantRepository participantRepository; + + @Value("${github.yeon-personalKey}") + private String githubToken; + @Value("${github.yeon-githubId}") + private String githubId; + @Value("${github.yeon-repository}") + private String targetRepo; + + @Nested + @DisplayName("챌린지 참여") + class Describe_joinChallenge { + + @Nested + @DisplayName("유효한 요청이 주어졌을 때") + class Context_with_validRequest { + + private User savedUser; + private Instance instance; + private JoinRequest joinRequest; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + instance = getSavedInstance(Progress.PREACTIVITY); + LocalDate todayDate = LocalDate.of(2024, 1, 30); + joinRequest = JoinRequest.builder() + .instanceId(instance.getId()) + .repository(targetRepo) + .todayDate(todayDate) + .build(); + } + + @Test + @DisplayName("참여 정보가 저장된다.") + void it_savesParticipantInfo() { + JoinResponse joinResponse = instanceDetailFacade.joinNewChallenge(savedUser, joinRequest); + + assertThat(joinResponse.joinStatus()).isEqualTo(JoinStatus.YES); + assertThat(joinResponse.joinResult()).isEqualTo(JoinResult.READY); + assertThat(instance.getParticipantCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("인스턴스가 존재하지 않을 때") + class Context_when_instanceNotExist { + + private User savedUser; + private JoinRequest joinRequest; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + joinRequest = JoinRequest.builder() + .instanceId(1L) // 존재하지 않는 인스턴스 ID + .repository(targetRepo) + .build(); + } + + @Test + @DisplayName("예외가 발생한다.") + void it_throwsException() { + assertThatThrownBy(() -> instanceDetailFacade.joinNewChallenge(savedUser, joinRequest)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INSTANCE_NOT_FOUND.getMessage()); + } + } + + @Nested + @DisplayName("인스턴스의 상태가 시작 전이 아닌 경우") + class Context_when_instanceProgressNotPreactivity { + + private User savedUser; + private Instance instance; + private JoinRequest joinRequest; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + instance = getSavedInstance(Progress.ACTIVITY); + joinRequest = JoinRequest.builder() + .repository(targetRepo) + .instanceId(instance.getId()) + .todayDate(LocalDate.of(2024, 1, 30)) + .build(); + } + + @ParameterizedTest + @EnumSource(value = Progress.class, mode = Mode.INCLUDE, names = {"ACTIVITY", "DONE"}) + @DisplayName("예외가 발생한다.") + void it_throwsException(Progress progress) { + instance.updateProgress(progress); + + assertThatThrownBy(() -> instanceDetailFacade.joinNewChallenge(savedUser, joinRequest)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_JOIN_INSTANCE.getMessage()); + } + } + + @Nested + @DisplayName("이미 참여한 경우") + class Context_when_userAlreadyJoined { + + private User savedUser; + private Instance instance; + private JoinRequest joinRequest; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + instance = getSavedInstance(Progress.PREACTIVITY); + joinRequest = JoinRequest.builder() + .repository(targetRepo) + .instanceId(instance.getId()) + .todayDate(LocalDate.of(2024, 1, 30)) + .build(); + instanceDetailFacade.joinNewChallenge(savedUser, joinRequest); + } + + @Test + @DisplayName("예외가 발생한다.") + void it_throwsException() { + assertThatThrownBy(() -> instanceDetailFacade.joinNewChallenge(savedUser, joinRequest)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_JOIN_INSTANCE.getMessage()); + } + } + + @Nested + @DisplayName("챌린지 시작 당일에 참여 요청을 했을 때") + class Context_when_joinAtStartedDate { + + private User savedUser; + private Instance instance; + private JoinRequest joinRequest; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + instance = getSavedInstance(Progress.PREACTIVITY, LocalDate.of(2024, 1, 30)); + joinRequest = JoinRequest.builder() + .repository(targetRepo) + .instanceId(instance.getId()) + .todayDate(LocalDate.of(2024, 1, 30)) + .build(); + } + + @Test + @DisplayName("예외가 발생한다.") + void it_throwsException() { + assertThatThrownBy(() -> instanceDetailFacade.joinNewChallenge(savedUser, joinRequest)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_JOIN_INSTANCE.getMessage()); + } + } + } + + @Nested + @DisplayName("챌린지 취소") + class Describe_quitChallenge { + + @Nested + @DisplayName("아직 시작하지 않은 챌린지에 대해 취소 요청을 했을 때") + class Context_when_quitBeforeStart { + + private User savedUser; + private Instance savedInstance; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + savedInstance = getSavedInstance(Progress.PREACTIVITY); + } + + @Test + @DisplayName("ParticipantInfo가 삭제된다.") + void it_deletesParticipantInfo() { + instanceDetailFacade.joinNewChallenge(savedUser, + new JoinRequest(savedInstance.getId(), targetRepo, LocalDate.of(2024, 1, 30))); + JoinResponse joinResponse = instanceDetailFacade.quitChallenge(savedUser, savedInstance.getId()); + + assertThat(savedInstance.getParticipantCount()).isEqualTo(0); + assertThat(joinResponse.participantId()).isNull(); + assertThat(joinResponse.joinResult()).isNull(); + assertThat(joinResponse.joinStatus()).isNull(); + } + } + + @Nested + @DisplayName("진행 중인 챌린지에 대해 취소 요청을 했을 때") + class Context_when_quitDuringActivity { + + private User savedUser; + private Instance savedInstance; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + savedInstance = getSavedInstance(Progress.PREACTIVITY); + instanceDetailFacade.joinNewChallenge(savedUser, + new JoinRequest(savedInstance.getId(), targetRepo, LocalDate.of(2024, 1, 30))); + savedInstance.updateProgress(Progress.ACTIVITY); + } + + @Test + @DisplayName("참여 인원 수가 줄어들고, 참여 정보가 변경된다.") + void it_changesParticipantInfo() { + instanceDetailFacade.quitChallenge(savedUser, savedInstance.getId()); + Participant participant = participantService.findByJoinInfo(savedUser.getId(), + savedInstance.getId()); + + assertThat(savedInstance.getParticipantCount()).isEqualTo(0); + assertThat(participant.getJoinResult()).isEqualTo(JoinResult.FAIL); + assertThat(participant.getJoinStatus()).isEqualTo(JoinStatus.NO); + } + } + + @Nested + @DisplayName("인스턴스가 존재하지 않을 때") + class Context_when_instanceNotExist { + + private User savedUser; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + } + + @Test + @DisplayName("예외가 발생한다.") + void it_throwsException() { + assertThatThrownBy(() -> instanceDetailFacade.quitChallenge(savedUser, 1L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INSTANCE_NOT_FOUND.getMessage()); + } + } + + @Nested + @DisplayName("참여 정보가 존재하지 않을 때") + class Context_when_participantInfoNotExist { + + private User savedUser; + private Instance savedInstance; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + savedInstance = getSavedInstance(Progress.PREACTIVITY); + } + + @Test + @DisplayName("예외가 발생한다.") + void it_throwsException() { + assertThatThrownBy(() -> instanceDetailFacade.quitChallenge(savedUser, savedInstance.getId())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(PARTICIPANT_NOT_FOUND.getMessage()); + } + } + + @Nested + @DisplayName("진행 상황이 DONE인 챌린지에 대해 취소 요청을 했을 때") + class Context_when_progressIsDone { + + @Test + @DisplayName("인스턴스의 진행 상황이 DONE이면 예외가 발생한다.") + public void should_throwException_when_progressIsDONE() { + //given + User savedUser = getSavedUser(githubId); + Instance savedInstance = getSavedInstance(Progress.PREACTIVITY); + LocalDate todayDate = LocalDate.of(2024, 1, 30); + JoinRequest joinRequest = JoinRequest.builder() + .instanceId(savedInstance.getId()) + .repository(targetRepo) + .todayDate(todayDate) + .build(); + + //when + instanceDetailFacade.joinNewChallenge(savedUser, joinRequest); + savedInstance.updateProgress(Progress.DONE); + + //then + assertThatThrownBy(() -> instanceDetailFacade.quitChallenge(savedUser, savedInstance.getId())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_QUIT_INSTANCE.getMessage()); + } + } + } + + @Nested + @DisplayName("상세 조회") + class Describe_getInstanceDetailInformation { + + @Nested + @DisplayName("사용자가 참여한 인스턴스의 상세 정보를 조회할 때") + class Context_when_joinedInstance { + + private User savedUser; + private Instance savedInstance; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + savedInstance = getSavedInstance(Progress.PREACTIVITY, LocalDate.now().plusDays(2)); + } + + @Test + @DisplayName("필요한 데이터를 반환한다.") + void it_returnsInstanceDetail() { + instanceDetailFacade.joinNewChallenge(savedUser, + new JoinRequest(savedInstance.getId(), targetRepo, LocalDate.of(2024, 1, 30))); + InstanceResponse instanceResponse = instanceDetailFacade.getInstanceDetailInformation(savedUser, + savedInstance.getId()); + + assertThat(instanceResponse.instanceId()).isEqualTo(savedInstance.getId()); + assertThat(instanceResponse.progress()).isEqualTo(Progress.PREACTIVITY); + assertThat(instanceResponse.remainDays()).isEqualTo(2); + assertThat(instanceResponse.participantCount()).isEqualTo(1); + assertThat(instanceResponse.pointPerPerson()).isEqualTo(100); + assertThat(instanceResponse.description()).isEqualTo(savedInstance.getDescription()); + assertThat(instanceResponse.joinStatus()).isEqualTo(JoinStatus.YES); + assertThat(instanceResponse.likesInfo().likesCount()).isEqualTo(0); + assertThat(instanceResponse.likesInfo().likesId()).isEqualTo(0); + assertThat(instanceResponse.likesInfo().isLiked()).isFalse(); + } + } + + @Nested + @DisplayName("사용자가 참여하지 않은 인스턴스의 상세 정보를 조회할 때") + class Context_when_notJoinedInstance { + + private User savedUser; + private Instance savedInstance; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + savedInstance = getSavedInstance(Progress.PREACTIVITY, LocalDate.now().plusDays(2)); + } + + @Test + @DisplayName("필요한 정보를 반환할 수 있다.") + void it_returnsInstanceDetail() { + InstanceResponse instanceResponse = instanceDetailFacade.getInstanceDetailInformation(savedUser, + savedInstance.getId()); + + assertThat(instanceResponse.instanceId()).isEqualTo(savedInstance.getId()); + assertThat(instanceResponse.remainDays()).isEqualTo(2); + assertThat(instanceResponse.participantCount()).isEqualTo(0); + assertThat(instanceResponse.pointPerPerson()).isEqualTo(100); + assertThat(instanceResponse.description()).isEqualTo(savedInstance.getDescription()); + assertThat(instanceResponse.joinStatus()).isEqualTo(JoinStatus.NO); + assertThat(instanceResponse.likesInfo().likesCount()).isEqualTo(0); + assertThat(instanceResponse.likesInfo().likesId()).isEqualTo(0); + assertThat(instanceResponse.likesInfo().isLiked()).isFalse(); + } + } + + @Nested + @DisplayName("좋아요를 한 이후 상세 정보를 조회할 때") + class Context_when_userPushLikes { + + private User savedUser; + private Instance savedInstance; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + savedInstance = getSavedInstance(Progress.PREACTIVITY, LocalDate.now().plusDays(2)); + likesFacade.addLikes(savedUser, savedUser.getIdentifier(), savedInstance.getId()); + } + + @Test + @DisplayName("좋아요 관련된 정보를 반환한다.") + void it_returnsLikesData() { + InstanceResponse instanceResponse = instanceDetailFacade.getInstanceDetailInformation(savedUser, + savedInstance.getId()); + + assertThat(instanceResponse.instanceId()).isEqualTo(savedInstance.getId()); + assertThat(instanceResponse.remainDays()).isEqualTo(2); + assertThat(instanceResponse.participantCount()).isEqualTo(0); + assertThat(instanceResponse.pointPerPerson()).isEqualTo(100); + assertThat(instanceResponse.description()).isEqualTo(savedInstance.getDescription()); + assertThat(instanceResponse.joinStatus()).isEqualTo(JoinStatus.NO); + assertThat(instanceResponse.likesInfo().likesCount()).isEqualTo(1); + assertThat(instanceResponse.likesInfo().likesId()).isNotEqualTo(0); + assertThat(instanceResponse.likesInfo().isLiked()).isTrue(); + } + } + + @Nested + @DisplayName("상세 정보를 요청하는 인스턴스가 존재하지 않을 때") + class Context_when_instanceNotExist { + + private User savedUser; + + @BeforeEach + void setUp() { + savedUser = getSavedUser(githubId); + } + + @Test + @DisplayName("예외가 발생해야 한다.") + void it_throwsException() { + assertThatThrownBy(() -> instanceDetailFacade.getInstanceDetailInformation(savedUser, 1L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INSTANCE_NOT_FOUND.getMessage()); + } + } + } + + // 유틸리티 메서드 + private User getSavedUser(String githubId) { + User user = userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier(githubId) + .information("information") + .tags("BE,FE") + .build() + ); + githubFacade.registerGithubPersonalToken(user, githubToken); + return user; + } + + private Instance getSavedInstance(Progress progress) { + return instanceRepository.save( + Instance.builder() + .progress(progress) + .startedDate(LocalDateTime.of(2024, 2, 1, 11, 3)) + .build() + ); + } + + private Instance getSavedInstance(Progress progress, LocalDate startedDate) { + return instanceRepository.save( + Instance.builder() + .progress(progress) + .description("description") + .notice("notice") + .certificationMethod("certification method") + .pointPerPerson(100) + .startedDate(startedDate.atTime(12, 12)) + .completedDate(startedDate.plusDays(30).atTime(12, 12)) + .build() + ); + } +} diff --git a/src/test/java/com/genius/gitget/challenge/instance/service/InstanceFacadeTest.java b/src/test/java/com/genius/gitget/challenge/instance/service/InstanceFacadeTest.java new file mode 100644 index 00000000..f1eac0c3 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/service/InstanceFacadeTest.java @@ -0,0 +1,347 @@ +package com.genius.gitget.challenge.instance.service; + + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.dto.crud.InstanceCreateRequest; +import com.genius.gitget.challenge.instance.dto.crud.InstanceDetailResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstancePagingResponse; +import com.genius.gitget.challenge.instance.dto.crud.InstanceUpdateRequest; +import com.genius.gitget.challenge.instance.facade.InstanceFacade; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.instance.util.TestDTOFactory; +import com.genius.gitget.challenge.instance.util.TestSetup; +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.repository.FilesRepository; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.assertj.core.api.Assertions; +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.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Rollback +public class InstanceFacadeTest { + @Autowired + InstanceRepository instanceRepository; + @Autowired + TopicRepository topicRepository; + @Autowired + FilesManager filesManager; + @Autowired + FilesRepository filesRepository; + @Autowired + InstanceFacade instanceFacade; + + private Instance instanceA; + private Topic topicA; + + @BeforeEach + public void setup() { + List topicList = TestSetup.createTopicList(); + List instanceList = TestSetup.createInstanceList(); + + topicA = topicList.get(0); + instanceA = instanceList.get(0); + + } + + private Topic getSavedTopic(String title, String tags) { + Topic topic = topicRepository.save( + Topic.builder() + .title(title) + .tags(tags) + .description("토픽 설명") + .pointPerPerson(100) + .build() + ); + return topic; + } + + private Instance getSavedInstance(String title, String tags, int participantCnt) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(100) + .certificationMethod("인증 방법") + .startedDate(now.plusDays(5)) + .completedDate(now.plusDays(10)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } + + private Files getSavedFiles(String originalFilename, String savedFilename, String fileURL, FileType fileType) { + return filesRepository.save( + Files.builder() + .originalFilename(originalFilename) + .savedFilename(savedFilename) + .fileURI(fileURL) + .fileType(fileType) + .build() + ); + } + + @Nested + @DisplayName("인스턴스 생성 메서드는") + class Describe_instance_create { + @Nested + @DisplayName("instanceCreateRequestDto가 들어오면") + class Context_with_a_instanceCreateRequestDto { + @Test + @DisplayName("인스턴스을 생성한다.") + public void it_returns_2XX_if_the_instance_was_created_successfully() { + LocalDate currentDate = instanceA.getStartedDate().minusDays(3).toLocalDate(); + Topic savedTopic = topicRepository.save(topicA); + + InstanceCreateRequest instanceCreateRequest = TestDTOFactory.getInstanceCreateRequest(savedTopic, + instanceA); + instanceFacade.createInstance(instanceCreateRequest, currentDate); + + List instanceList = instanceRepository.findAll(); + Assertions.assertThat(instanceList.size()).isEqualTo(1); + } + } + } + + @Nested + @DisplayName("Instance 수정 메서드는") + class Describe_instance_modify { + + @Nested + @DisplayName("InstanceUpdateRequest가 주어지면") + class Context_with_an_instanceUpdateRequest { + + @Test + @DisplayName("인스턴스를 수정하고 수정된 내용을 반환한다") + void it_updates_the_instance_and_returns_the_updated_content() throws Exception { + LocalDate currentDate = instanceA.getStartedDate().minusDays(3).toLocalDate(); + Topic savedTopic = topicRepository.save(topicA); + + InstanceCreateRequest instanceCreateRequest = TestDTOFactory.getInstanceCreateRequest(savedTopic, + instanceA); + Long savedInstanceId = instanceFacade.createInstance(instanceCreateRequest, currentDate); + + InstanceUpdateRequest instanceUpdateRequest = InstanceUpdateRequest.builder() + .topicId(savedTopic.getId()) + .description("이것은 수정본이지롱") + .pointPerPerson(instanceA.getPointPerPerson()) + .startedAt(instanceA.getStartedDate()) + .completedAt(instanceA.getCompletedDate()) + .build(); + + Long updatedInstanceId = instanceFacade.modifyInstance(savedInstanceId, instanceUpdateRequest); + + Optional byId = instanceRepository.findById(updatedInstanceId); + Assertions.assertThat(byId.get().getDescription()).isEqualTo("이것은 수정본이지롱"); + } + } + } + + @Nested + @DisplayName("Instance 단건 조회 메서드는") + class Describe_instance_findOne { + + @Nested + @DisplayName("Instance ID가 주어졌을 때") + class Context_with_an_instanceId { + + @Test + @DisplayName("해당 ID의 인스턴스를 반환한다") + void it_returns_the_instance_with_the_given_id() throws Exception { + LocalDate currentDate = instanceA.getStartedDate().minusDays(3).toLocalDate(); + Topic savedTopic = topicRepository.save(topicA); + + InstanceCreateRequest instanceCreateRequest = TestDTOFactory.getInstanceCreateRequest(savedTopic, + instanceA); + Long savedInstanceId = instanceFacade.createInstance(instanceCreateRequest, currentDate); + + //wen + InstanceDetailResponse instanceById = instanceFacade.findOne(savedInstanceId); + + Assertions.assertThat(instanceById.title()).isEqualTo(instanceCreateRequest.title()); + } + } + } + + @Nested + @DisplayName("Instance 리스트 조회 메서드는") + class Describe_instance_findAllInstances { + + @Nested + @DisplayName("페이지 요청이 주어졌을 때") + class Context_with_a_pageRequest { + + @Test + @DisplayName("모든 인스턴스를 페이지로 반환한다") + void it_returns_all_instances_in_a_page() { + Topic savedTopic = getSavedTopic("1일 1알고리즘", "FE, BE"); + Instance savedInstance1 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance2 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance3 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + + savedInstance1.setTopic(savedTopic); + savedInstance2.setTopic(savedTopic); + savedInstance3.setTopic(savedTopic); + + PageRequest pageRequest = PageRequest.of(0, 10); + Page allInstances = instanceFacade.findAllInstances(pageRequest); + + Assertions.assertThat(allInstances.getTotalElements()).isEqualTo(3); + } + } + } + + + @Nested + @DisplayName("특정 토픽에 대한 인스턴스 리스트 조회 메서드는") + class Describe_instance_getAllInstancesOfSpecificTopic { + @Nested + @DisplayName("특정 토픽 ID가 주어졌을 때") + class Context_with_a_specificTopicId { + + @Test + @DisplayName("해당 토픽1의 모든 인스턴스를 페이지로 반환한다") + void it_returns_all_instances_of_topic1_in_a_page() { + Topic savedTopic1 = getSavedTopic("1일 1알고리즘", "FE, BE"); + Topic savedTopic2 = getSavedTopic("1일 1알고리즘", "FE, BE"); + Instance savedInstance1 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance2 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance3 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + + savedInstance1.setTopic(savedTopic1); + savedInstance2.setTopic(savedTopic1); + savedInstance3.setTopic(savedTopic2); + + PageRequest pageRequest = PageRequest.of(0, 10); + Page allInstancesOfSpecificTopic = instanceFacade.getAllInstancesOfSpecificTopic( + pageRequest, savedTopic1.getId()); + + Assertions.assertThat(allInstancesOfSpecificTopic.getTotalElements()).isEqualTo(2); + } + + @Test + @DisplayName("해당 토픽2의 모든 인스턴스를 페이지로 반환한다") + void it_returns_all_instances_of_topic2_in_a_page() { + Topic savedTopic1 = getSavedTopic("1일 1알고리즘", "FE, BE"); + Topic savedTopic2 = getSavedTopic("1일 1알고리즘", "FE, BE"); + Instance savedInstance1 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance2 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance3 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + + savedInstance1.setTopic(savedTopic1); + savedInstance2.setTopic(savedTopic1); + savedInstance3.setTopic(savedTopic2); + + PageRequest pageRequest = PageRequest.of(0, 10); + Page allInstancesOfSpecificTopic = instanceFacade.getAllInstancesOfSpecificTopic( + pageRequest, savedTopic2.getId()); + + Assertions.assertThat(allInstancesOfSpecificTopic.getTotalElements()).isEqualTo(1); + } + + + @Test + @DisplayName("해당 토픽의 모든 인스턴스를 페이지로 반환한다") + void it_returns_all_instances_of_the_given_topic_in_a_page() { + Topic savedTopic1 = getSavedTopic("1일 1알고리즘", "FE, BE"); + Topic savedTopic2 = getSavedTopic("1일 1알고리즘", "FE, BE"); + Instance savedInstance1 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance2 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + Instance savedInstance3 = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + + savedInstance1.setTopic(savedTopic1); + savedInstance2.setTopic(savedTopic1); + savedInstance3.setTopic(savedTopic2); + + PageRequest pageRequest = PageRequest.of(0, 10); + Page allInstancesOfSpecificTopic = instanceFacade.getAllInstancesOfSpecificTopic( + pageRequest, savedTopic1.getId()); + + Assertions.assertThat(allInstancesOfSpecificTopic.getTotalElements()).isEqualTo(2); + } + } + } + + @Nested + @DisplayName("Instance 삭제 메서드는") + class Describe_instance_remove { + + @Nested + @DisplayName("Instance ID가 주어졌을 때") + class Context_with_an_instanceId { + + @Test + @DisplayName("해당 인스턴스를 삭제하고, 삭제된 인스턴스를 조회할 때 예외를 던진다") + void it_removes_the_instance_and_throws_exception_when_retrieving_deleted_instance() { + Instance savedInstance = getSavedInstance("1일 1알고리즘", "FE, BE", 50); + + instanceFacade.removeInstance(savedInstance.getId()); + + assertThrows(BusinessException.class, () -> instanceFacade.findOne(savedInstance.getId())); + } + } + + @Nested + @DisplayName("인스턴스를 삭제할 때") + class Context_when_removing_an_instance { + private Topic topic; + private Instance instance1, instance2, instance3; + + @BeforeEach + public void setup() { + topic = getSavedTopic("1일 1공부", "BE, ML"); + instance1 = getSavedInstance("1일 1공부", "BE, ML", 100); + instance2 = getSavedInstance("1일 3공부", "BE, ML", 100); + instance3 = getSavedInstance("1일 3공부", "BE, ML", 100); + instance1.setTopic(topic); + instance2.setTopic(topic); + } + + @Test + @DisplayName("해당 아이디가 존재한다면 삭제할 수 있다") + void it_can_remove_if_the_instance_exists() { + Long id = instance1.getId(); + + instanceFacade.removeInstance(id); + + assertThrows(BusinessException.class, () -> { + instanceFacade.findOne(id); + }); + } + + @Test + @DisplayName("해당 아이디가 존재하지 않는다면 삭제할 수 없다") + void it_cannot_remove_if_the_instance_does_not_exist() { + Long id = instance3.getId() + 1L; + + assertThrows(BusinessException.class, () -> { + instanceFacade.removeInstance(id); + }); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/instance/service/InstanceHomeFacadeTest.java b/src/test/java/com/genius/gitget/challenge/instance/service/InstanceHomeFacadeTest.java new file mode 100644 index 00000000..97c4b869 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/service/InstanceHomeFacadeTest.java @@ -0,0 +1,91 @@ +package com.genius.gitget.challenge.instance.service; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchRequest; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchResponse; +import com.genius.gitget.challenge.instance.facade.InstanceFacade; +import com.genius.gitget.challenge.instance.facade.InstanceHomeFacade; +import com.genius.gitget.challenge.instance.util.TestDTOFactory; +import com.genius.gitget.challenge.instance.util.TestSetup; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import java.time.LocalDate; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +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.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Rollback +@Slf4j +public class InstanceHomeFacadeTest { + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceService instanceService; + + @Autowired + InstanceFacade instanceFacade; + @Autowired + InstanceHomeFacade instanceHomeFacade; + + private Instance instanceA, instanceB, instanceC; + private Topic topicA; + + @BeforeEach + public void setup() { + topicA = TestSetup.createTopicList().get(0); + instanceA = TestSetup.createInstanceList().get(0); + instanceB = TestSetup.createInstanceList().get(1); + instanceC = TestSetup.createInstanceList().get(2); + } + + @SpringBootTest + public class InstanceFacadeTest { + + @Nested + @DisplayName("Instance 검색 메서드는") + class Describe_instance_search { + + @Nested + @DisplayName("키워드와 진행 상태가 주어졌을 때") + class Context_with_keyword_and_progress { + + @Test + @DisplayName("해당 조건에 맞는 인스턴스를 반환한다") + void it_returns_instances_matching_the_given_conditions() { + Topic savedTopic = topicRepository.save(topicA); + LocalDate currentDate = instanceA.getStartedDate().minusDays(3).toLocalDate(); + + instanceFacade.createInstance(TestDTOFactory.getInstanceCreateRequest(savedTopic, instanceA), + currentDate); + instanceFacade.createInstance(TestDTOFactory.getInstanceCreateRequest(savedTopic, instanceB), + currentDate); + instanceFacade.createInstance(TestDTOFactory.getInstanceCreateRequest(savedTopic, instanceC), + currentDate); + + int instanceCount = 0; + Page orderList = instanceHomeFacade.searchInstancesByKeywordAndProgress( + InstanceSearchRequest.builder().keyword("이펙티브").progress("preactivity").build(), + PageRequest.of(0, 3)); + + for (InstanceSearchResponse instanceSearchResponse : orderList) { + if (instanceSearchResponse.getKeyword() != null) { + instanceCount++; + } + } + Assertions.assertThat(instanceCount).isEqualTo(1); + } + } + } + } +} diff --git a/src/test/java/com/genius/gitget/challenge/instance/service/ProgressServiceTest.java b/src/test/java/com/genius/gitget/challenge/instance/service/ProgressServiceTest.java new file mode 100644 index 00000000..f4aa324f --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/service/ProgressServiceTest.java @@ -0,0 +1,278 @@ +package com.genius.gitget.challenge.instance.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.facade.GithubFacade; +import com.genius.gitget.challenge.certification.repository.CertificationRepository; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.dto.detail.JoinRequest; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.schedule.service.ProgressService; +import java.time.LocalDate; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +@ActiveProfiles({"github"}) +class ProgressServiceTest { + @Autowired + private ProgressService scheduleService; + @Autowired + private GithubFacade githubFacade; + @Autowired + private InstanceRepository instanceRepository; + @Autowired + private ParticipantRepository participantRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private CertificationRepository certificationRepository; + @Autowired + private InstanceDetailFacade instanceDetailFacade; + + + @Value("${github.yeon-personalKey}") + private String personalKey; + @Value("${github.yeon-githubId}") + private String githubId; + @Value("${github.yeon-repository}") + private String targetRepo; + + @Test + @DisplayName("PRE_ACTIVITY 인스턴스들 중, 특정 조건에 해당하는 인스턴스들을 ACTIVITY로 상태를 바꿀 수 있다.") + public void should_updateToActivity_when_conditionMatches() { + //given + LocalDate todayDate = LocalDate.of(2024, 1, 30); + LocalDate startedDate = LocalDate.of(2024, 3, 1); + LocalDate completedDate = LocalDate.of(2024, 3, 30); + LocalDate currentDate = LocalDate.of(2024, 3, 6); + + User user = getSavedUser("nickname1", githubId); + Instance instance1 = getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + + githubFacade.registerGithubPersonalToken(user, personalKey); + instanceDetailFacade.joinNewChallenge( + user, + JoinRequest.builder() + .repository(targetRepo) + .instanceId(instance1.getId()) + .todayDate(todayDate) + .build() + ); + + Participant participant1 = participantRepository.findByJoinInfo(user.getId(), instance1.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PARTICIPANT_NOT_FOUND)); + + //when + List preActivities = instanceRepository.findAllByProgress(Progress.PREACTIVITY); + assertThat(participant1.getJoinResult()).isEqualTo(JoinResult.READY); + scheduleService.updateToActivity(currentDate); + List activities = instanceRepository.findAllByProgress(Progress.ACTIVITY); + + //then + assertThat(preActivities.size()).isEqualTo(3); + assertThat(activities.size()).isEqualTo(3); + assertThat(participant1.getJoinResult()).isEqualTo(JoinResult.PROCESSING); + } + + @Test + @DisplayName("PREACTIVITY 인스턴스들 중, 시작날짜가 된 인스턴스들을 ACTIVITY 상태로 바꿀 수 있다.") + public void should_updateToActivity_when_dDay() { + //given + LocalDate todayDate = LocalDate.of(2024, 1, 30); + LocalDate startedDate = LocalDate.of(2024, 3, 1); + LocalDate completedDate = LocalDate.of(2024, 3, 30); + LocalDate currentDate = LocalDate.of(2024, 3, 1); + + User user = getSavedUser("nickname1", githubId); + Instance instance1 = getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + + githubFacade.registerGithubPersonalToken(user, personalKey); + instanceDetailFacade.joinNewChallenge( + user, + JoinRequest.builder() + .repository(targetRepo) + .instanceId(instance1.getId()) + .todayDate(todayDate) + .build() + ); + + Participant participant1 = participantRepository.findByJoinInfo(user.getId(), instance1.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PARTICIPANT_NOT_FOUND)); + + //when + List preActivities = instanceRepository.findAllByProgress(Progress.PREACTIVITY); + assertThat(participant1.getJoinResult()).isEqualTo(JoinResult.READY); + scheduleService.updateToActivity(currentDate); + List activities = instanceRepository.findAllByProgress(Progress.ACTIVITY); + + //then + assertThat(preActivities.size()).isEqualTo(3); + assertThat(activities.size()).isEqualTo(3); + assertThat(participant1.getJoinResult()).isEqualTo(JoinResult.PROCESSING); + } + + @Test + @DisplayName("ACTIVITY 인스턴스들 중, 특정 조건에 해당하는 인스턴스들을 DONE 상태로 바꿀 수 있다.") + public void should_updateToDone_when_conditionMatches() { + //given + LocalDate startedDate = LocalDate.of(2024, 3, 1); + LocalDate completedDate = LocalDate.of(2024, 3, 30); + LocalDate currentDate = LocalDate.of(2024, 4, 1); + + getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + + //when + List activities = instanceRepository.findAllByProgress(Progress.PREACTIVITY); + scheduleService.updateToDone(currentDate); + List done = instanceRepository.findAllByProgress(Progress.DONE); + + //then + assertThat(activities.size()).isEqualTo(3); + assertThat(done.size()).isEqualTo(3); + } + + @Test + @DisplayName("DONE으로 상태를 바꾸면서 성공률이 85.5% 이상이라면 JoinResult를 SUCCESS로 변경한다.") + public void should_updateToSuccess_then_rateOverThreshold() { + //given + LocalDate startedDate = LocalDate.of(2024, 3, 1); + LocalDate completedDate = LocalDate.of(2024, 3, 2); + LocalDate currentDate = LocalDate.of(2024, 4, 1); + + Instance instance = getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + + Participant participant1 = getSavedParticipant(getSavedUser("nickname1"), instance); + + getSavedCertification(CertificateStatus.CERTIFICATED, startedDate, participant1); + getSavedCertification(CertificateStatus.PASSED, completedDate, participant1); + + //when + scheduleService.updateToDone(currentDate); + + //then + List done = instanceRepository.findAllByProgress(Progress.DONE); + assertThat(done.size()).isEqualTo(3); + assertThat(participant1.getJoinResult()).isEqualTo(JoinResult.SUCCESS); + } + + @Test + @DisplayName("DONE으로 상태를 바꾸면서 성공률이 85% 이하라면 JoinResult를 FAIL로 변경한다.") + public void should_updateToFail_when_rateUnderThreshold() { + //given + LocalDate startedDate = LocalDate.of(2024, 3, 1); + LocalDate completedDate = LocalDate.of(2024, 3, 2); + LocalDate currentDate = LocalDate.of(2024, 4, 1); + + Instance instance = getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + getSavedInstance(startedDate, completedDate); + + Participant participant1 = getSavedParticipant(getSavedUser("nickname1"), instance); + + getSavedCertification(CertificateStatus.NOT_YET, startedDate, participant1); + getSavedCertification(CertificateStatus.PASSED, completedDate, participant1); + + //when + scheduleService.updateToDone(currentDate); + + //then + List done = instanceRepository.findAllByProgress(Progress.DONE); + assertThat(done.size()).isEqualTo(3); + assertThat(participant1.getJoinResult()).isEqualTo(JoinResult.FAIL); + } + + private Instance getSavedInstance(LocalDate startedDate, LocalDate completedDate) { + return instanceRepository.save( + Instance.builder() + .title("title") + .progress(Progress.PREACTIVITY) + .pointPerPerson(100) + .startedDate(startedDate.atTime(0, 0)) + .completedDate(completedDate.atTime(0, 0)) + .build() + ); + } + + private Participant getSavedParticipant(User user, Instance instance) { + Participant participant = participantRepository.save( + Participant.builder() + .joinResult(JoinResult.PROCESSING) + .joinStatus(JoinStatus.YES) + .build() + ); + participant.setUserAndInstance(user, instance); + return participant; + } + + private User getSavedUser(String nickname, String githubId) { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname(nickname) + .providerInfo(ProviderInfo.GITHUB) + .identifier(githubId) + .information("information") + .tags("BE,FE") + .build() + ); + } + + private User getSavedUser(String nickname) { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname(nickname) + .providerInfo(ProviderInfo.GITHUB) + .identifier("identifier") + .information("information") + .tags("BE,FE") + .build() + ); + } + + private Certification getSavedCertification(CertificateStatus status, LocalDate certificatedAt, + Participant participant) { + int attempt = DateUtil.getAttemptCount(participant.getStartedDate(), certificatedAt); + Certification certification = Certification.builder() + .certificationStatus(status) + .currentAttempt(attempt) + .certificatedAt(certificatedAt) + .certificationLinks("certificationLink") + .build(); + certification.setParticipant(participant); + return certificationRepository.save(certification); + } + +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/instance/util/TestDTOFactory.java b/src/test/java/com/genius/gitget/challenge/instance/util/TestDTOFactory.java new file mode 100644 index 00000000..49adbafa --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/util/TestDTOFactory.java @@ -0,0 +1,55 @@ +package com.genius.gitget.challenge.instance.util; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.dto.crud.InstanceCreateRequest; +import com.genius.gitget.challenge.instance.dto.search.InstanceSearchRequest; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.dto.TopicCreateRequest; +import java.time.LocalDateTime; +import org.springframework.stereotype.Component; + +@Component +public class TestDTOFactory { + public static TopicCreateRequest createTopicCreateRequest(String title, String description, String tags, + int pointPerPerson) { + return TopicCreateRequest.builder() + .title(title) + .description(description) + .tags(tags) + .pointPerPerson(pointPerPerson) + .build(); + } + + public static InstanceCreateRequest createInstanceCreateRequest(Long topicId, String title, String description, + String tags, int pointPerPerson) { + return InstanceCreateRequest.builder() + .topicId(topicId) + .title(title) + .description(description) + .tags(tags) + .pointPerPerson(pointPerPerson) + .startedAt(LocalDateTime.now()) + .completedAt(LocalDateTime.now().plusDays(3)) + .build(); + } + + public static InstanceSearchRequest createInstanceSearchRequest(String keyword, String progress) { + return InstanceSearchRequest.builder() + .keyword(keyword) + .progress(progress) + .build(); + } + + public static InstanceCreateRequest getInstanceCreateRequest(Topic savedTopic, Instance instance) { + return InstanceCreateRequest.builder() + .topicId(savedTopic.getId()) + .title(instance.getTitle()) + .tags(instance.getTags()) + .description(instance.getDescription()) + .notice(instance.getNotice()) + .pointPerPerson(instance.getPointPerPerson()) + .startedAt(instance.getStartedDate()) + .completedAt(instance.getCompletedDate()) + .build(); + } +} diff --git a/src/test/java/com/genius/gitget/challenge/instance/util/TestSetup.java b/src/test/java/com/genius/gitget/challenge/instance/util/TestSetup.java new file mode 100644 index 00000000..43603233 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/instance/util/TestSetup.java @@ -0,0 +1,100 @@ +package com.genius.gitget.challenge.instance.util; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.topic.domain.Topic; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class TestSetup { + public static List createTopicList() { + Topic topicA = Topic.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE") + .pointPerPerson(100) + .build(); + + Topic topicB = Topic.builder() + .title("이펙티브 자바") + .description("1주일에 2개 item씩 공부합니다.") + .tags("BE") + .pointPerPerson(500) + .build(); + + Topic topicC = Topic.builder() + .title("1일 1면접 준비") + .description("1일에 면접 주제 1개씩 공부합니다.") + .tags("BE, FE, CS") + .pointPerPerson(700) + .build(); + + return List.of(topicA, topicB, topicC); + } + + public static List createInstanceList() { + Instance instanceA = Instance.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE") + .pointPerPerson(100) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(10)) + .completedDate(LocalDateTime.now().plusDays(30)) + .build(); + + Instance instanceB = Instance.builder() + .title("1일 3알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE") + .pointPerPerson(300) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(10)) + .completedDate(LocalDateTime.now().plusDays(30)) + .build(); + + Instance instanceC = Instance.builder() + .title("이펙티브 자바") + .description("1주일에 2개 item씩 공부합니다.") + .tags("BE, FE, CS") + .pointPerPerson(500) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(10)) + .completedDate(LocalDateTime.now().plusDays(30)) + .build(); + + Instance instanceD = Instance.builder() + .title("이펙티브 자바") + .description("1주일에 1개 item씩 공부합니다.") + .tags("BE, FE, CS") + .pointPerPerson(400) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(10)) + .completedDate(LocalDateTime.now().plusDays(30)) + .build(); + + Instance instanceE = Instance.builder() + .title("1일 1면접 준비") + .description("1일에 면접 주제 1개씩 공부합니다.") + .tags("BE, FE, CS") + .pointPerPerson(700) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(10)) + .completedDate(LocalDateTime.now().plusDays(30)) + .build(); + + Instance instanceF = Instance.builder() + .title("3일 1면접 준비") + .description("1일에 면접 주제 3개씩 공부합니다.") + .tags("BE, FE, CS") + .pointPerPerson(1000) + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(10)) + .completedDate(LocalDateTime.now().plusDays(30)) + .build(); + + return List.of(instanceA, instanceB, instanceC, instanceD, instanceE, instanceF); + } +} diff --git a/src/test/java/com/genius/gitget/challenge/likes/LikesTest.java b/src/test/java/com/genius/gitget/challenge/likes/LikesTest.java new file mode 100644 index 00000000..5d306842 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/likes/LikesTest.java @@ -0,0 +1,94 @@ +package com.genius.gitget.challenge.likes; + +import static com.genius.gitget.challenge.user.domain.Role.ADMIN; +import static com.genius.gitget.challenge.user.domain.Role.USER; +import static com.genius.gitget.global.security.constants.ProviderInfo.GOOGLE; + +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +public class LikesTest { + + @Autowired + UserRepository userRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + LikesRepository likesRepository; + @Autowired + TopicRepository topicRepository; + + private User user1, user2; + private Instance instance1; + private Topic topic1; + + @BeforeEach + public void setup() { + user1 = User.builder().identifier("neo5188@gmail.com") + .providerInfo(ProviderInfo.NAVER) + .nickname("kimdozzi") + .information("백엔드") + .tags("운동") + .role(ADMIN) + .build(); + + user2 = User.builder().identifier("ssang23@naver.com") + .providerInfo(GOOGLE) + .nickname("SEONG") + .information("프론트엔드") + .tags("영화") + .role(USER) + .build(); + + instance1 = Instance.builder() + .title("1일 1커밋") + .description("챌린지 세부사항입니다.") + .pointPerPerson(10) + .tags("BE, CS") + .progress(Progress.ACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(3)) + .build(); + + topic1 = Topic.builder() + .title("1일 1커밋") + .description("간단한 설명란") + .pointPerPerson(300) + .tags("BE, CS") + .build(); + + userRepository.save(user1); + userRepository.save(user2); + + topicRepository.save(topic1); + instance1.setTopic(topic1); + instanceRepository.save(instance1); + } + + @Test + public void 사용자는_챌린지의_인스턴스를_관심목록에_저장한다() { + Likes like = new Likes(user1, instance1); + likesRepository.save(like); + + int likeCount = instance1.getLikesList().size(); + Assertions.assertEquals(1, likeCount); + + } +} diff --git a/src/test/java/com/genius/gitget/challenge/likes/controller/LikesControllerTest.java b/src/test/java/com/genius/gitget/challenge/likes/controller/LikesControllerTest.java new file mode 100644 index 00000000..f5e0417f --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/likes/controller/LikesControllerTest.java @@ -0,0 +1,256 @@ +package com.genius.gitget.challenge.likes.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.likes.dto.UserLikesAddRequest; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.likes.service.LikesService; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import java.time.LocalDateTime; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +public class LikesControllerTest { + private static Topic savedTopic1; + private static Instance savedInstance1, savedInstance2; + + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + FilesManager filesManager; + @Autowired + LikesService likesService; + @Autowired + UserRepository userRepository; + @Autowired + LikesRepository likesRepository; + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + savedTopic1 = getSavedTopic(); + + savedInstance1 = getSavedInstance("title1", "FE", 50, 1000); + savedInstance2 = getSavedInstance("title2", "BE, CS", 50, 1000); + + savedInstance1.setTopic(savedTopic1); + savedInstance2.setTopic(savedTopic1); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록을 조회하면, 상태 코드 200을 반환한다.") + public void 좋아요_목록_조회_성공_1() throws Exception { + User user = getSavedUser(); +// likesRepository.save(Likes.builder() +// .instance(savedInstance1) +// .user(user) +// .build()); + + likesService.addLikes(user, "kimdozzi", savedInstance1.getId()); + + mockMvc.perform(get("/api/profile/likes") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON)) + + .andDo(print()) + .andExpect(jsonPath("$.data.content[0].instanceId").value(savedInstance1.getId())) + .andExpect(jsonPath("$.data.content[0].title").value(savedInstance1.getTitle())) + .andExpect(status().isOk()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록을 조회하면, 상태 코드 200을 반환한다.") + public void 좋아요_목록_조회_성공_2() throws Exception { + + mockMvc.perform(get("/api/profile/likes") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(jsonPath("$.data.numberOfElements").value(0)) + .andExpect(status().isOk()); + } + + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록에 해당 챌린지 추가를 성공하면, 상태 코드 200을 반환한다.") + public void 좋아요_목록_추가_성공() throws Exception { + Long id = savedInstance1.getId(); + + UserLikesAddRequest request = UserLikesAddRequest.builder() + .identifier("kimdozzi") + .instanceId(id) + .build(); + + mockMvc.perform(post("/api/profile/likes") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록을 추가할 때, 사용자의 정보가 다르면 상태 코드 4xx를 반환한다.") + public void 좋아요_목록_추가_실패_1() throws Exception { + Long id = savedInstance1.getId(); + + UserLikesAddRequest request = UserLikesAddRequest.builder() + .identifier("park") + .instanceId(id) + .build(); + + mockMvc.perform(post("/api/profile/likes") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록을 추가할 때, contentType이 올바르지 않으면 상태 코드 4xx를 반환한다.") + public void 좋아요_목록_추가_실패_2() throws Exception { + Long id = savedInstance1.getId(); + + UserLikesAddRequest request = UserLikesAddRequest.builder() + .identifier("park") + .instanceId(id) + .build(); + + mockMvc.perform(post("/api/profile/likes") + .headers(tokenTestUtil.createAccessHeaders()) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록을 삭제 성공하면, 상태 코드 200을 반환한다.") + public void 좋아요_목록_삭제_성공() throws Exception { + User user = getSavedUser(); + Likes likes = likesRepository.save(Likes.builder() + .instance(savedInstance1) + .user(user) + .build()); + + Long id = likes.getId(); + + mockMvc.perform(delete("/api/profile/likes/" + id) + .headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()); + + Assertions.assertThat(likesRepository.findById(id)).isEmpty(); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi", role = Role.ADMIN) + @DisplayName("유저가 좋아요 목록을 삭제 실패하면, 상태 코드 4xx을 반환한다.") + public void 좋아요_목록_삭제_실패() throws Exception { + User user = getSavedUser(); + Likes likes = likesRepository.save(Likes.builder() + .instance(savedInstance1) + .user(user) + .build()); + + mockMvc.perform(delete("/api/profile/likes/" + 2) + .headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + + private User getSavedUser() { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname1") + .tags("FE, BE") + .providerInfo(ProviderInfo.GITHUB) + .identifier("kimdozzi") + .build() + ); + } + + + private Topic getSavedTopic() { + Topic topic = topicRepository.save( + Topic.builder() + .title("title") + .notice("notice") + .description("description") + .tags("BE") + .pointPerPerson(100) + .build() + ); + return topic; + } + + private Instance getSavedInstance(String title, String tags, int participantCnt, int pointPerPerson) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(pointPerPerson) + .certificationMethod("인증 방법") + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } +} diff --git a/src/test/java/com/genius/gitget/challenge/likes/service/LikesFacadeTest.java b/src/test/java/com/genius/gitget/challenge/likes/service/LikesFacadeTest.java new file mode 100644 index 00000000..976f06cf --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/likes/service/LikesFacadeTest.java @@ -0,0 +1,214 @@ +package com.genius.gitget.challenge.likes.service; + +import static com.genius.gitget.global.security.constants.ProviderInfo.GITHUB; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.likes.dto.UserLikesResponse; +import com.genius.gitget.challenge.likes.facade.LikesFacade; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +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; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class LikesFacadeTest { + private User userA; + private Topic topicA; + private Instance instanceA, instanceB, instanceC; + + @Autowired + private UserRepository userRepository; + + @Autowired + private InstanceRepository instanceRepository; + + @Autowired + private TopicRepository topicRepository; + + @Autowired + private LikesRepository likesRepository; + + @Autowired + private LikesFacade likesFacade; + + + @BeforeEach + void setup() { + userA = saveUser("test@gmail.com", GITHUB, "kimdozzi"); + + topicA = saveTopic("1일 1커밋", "BE"); + + instanceA = saveInstance("1일 1커밋", "BE", 50, 100); + instanceB = saveInstance("1일 1커밋", "BE", 50, 150); + instanceC = saveInstance("1일 1알고리즘", "CS,BE,FE", 50, 200); + + associateInstancesWithTopic(topicA, instanceA, instanceB, instanceC); + + saveLikes(userA, instanceA, instanceB, instanceC); + } + + @Nested + @DisplayName("좋아요 추가 메서드는") + class Describe_add_likes { + @Nested + @DisplayName("유저와 인스턴스가 주어지면") + class Context_with_a_user_and_instance { + @Test + @DisplayName("유저의 좋아요 목록에 추가된다") + void it_adds_the_instance_to_user_likes() { + likesFacade.addLikes(userA, "test@gmail.com", instanceA.getId()); + + List allLikes = likesRepository.findAll(); + long count = allLikes.stream() + .filter(like -> like.getUser().getIdentifier().equals("test@gmail.com") && like.getInstance() + .getTitle() + .equals("1일 1커밋")).count(); + + assertThat(count).isEqualTo(allLikes.size() - 1); + } + } + } + + @Nested + @DisplayName("좋아요 삭제 메서드는") + class Describe_delete_likes { + @Nested + @DisplayName("유저와 좋아요 ID가 주어지면") + class Context_with_a_user_and_likes_id { + @Test + @DisplayName("유저의 좋아요 목록에서 삭제된다") + void it_removes_the_instance_from_user_likes() { + List likesList = likesRepository.findAll(); + Long likesId = likesList.get(0).getId(); + + likesFacade.deleteLikes(userA, likesId); + + assertThrows(BusinessException.class, + () -> likesRepository.findById(likesId) + .orElseThrow(() -> new BusinessException(ErrorCode.LIKES_NOT_FOUND))); + + List allLikes = likesRepository.findAll(); + + assertThat(allLikes.size()).isEqualTo(2); + } + } + } + + @Nested + @DisplayName("유저의 좋아요 목록 조회 메서드는") + class Describe_get_likes_list { + + @Nested + @DisplayName("모든 좋아요 목록을 조회할 때") + class Context_when_retrieving_all_likes { + @Test + @DisplayName("유저의 좋아요 목록을 반환한다") + void it_returns_all_likes() { + List allLikes = likesRepository.findAll(); + + for (int i = 0; i < allLikes.size(); i++) { + if (i <= 1) { + assertThat(allLikes.get(i).getInstance().getTitle()).isEqualTo("1일 1커밋"); + } else { + assertThat(allLikes.get(i).getInstance().getTitle()).isEqualTo("1일 1알고리즘"); + } + } + assertThat(allLikes.size()).isEqualTo(3); + } + } + + @Nested + @DisplayName("페이징된 좋아요 목록을 조회할 때") + class Context_when_retrieving_paginated_likes { + @Test + @DisplayName("유저의 페이징된 좋아요 목록을 반환한다") + void it_returns_paginated_likes() { + PageRequest pageRequest = PageRequest.of(0, 5); + Page likesResponses = likesFacade.getLikesList(userA, pageRequest); + + assertThat(likesResponses.getContent().size()).isEqualTo(3); + assertThat(likesResponses.getContent().get(2).getTitle()).isEqualTo("1일 1커밋"); + assertThat(likesResponses.getContent().get(2).getPointPerPerson()).isEqualTo(100); + assertThat(likesResponses.getContent().get(1).getTitle()).isEqualTo("1일 1커밋"); + assertThat(likesResponses.getContent().get(1).getPointPerPerson()).isEqualTo(150); + assertThat(likesResponses.getContent().get(0).getTitle()).isEqualTo("1일 1알고리즘"); + assertThat(likesResponses.getContent().get(0).getPointPerPerson()).isEqualTo(200); + } + } + } + + private User saveUser(String identifier, ProviderInfo providerInfo, String nickname) { + return userRepository.save( + User.builder() + .identifier(identifier) + .providerInfo(providerInfo) + .role(Role.ADMIN) + .nickname(nickname) + .build() + ); + } + + private Topic saveTopic(String title, String tags) { + return topicRepository.save( + Topic.builder() + .title(title) + .tags(tags) + .description("토픽 설명") + .pointPerPerson(100) + .build() + ); + } + + private Instance saveInstance(String title, String tags, int participantCnt, int pointPerPerson) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(pointPerPerson) + .certificationMethod("인증 방법") + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } + + + private void associateInstancesWithTopic(Topic topic, Instance... instances) { + for (Instance instance : instances) { + instance.setTopic(topic); + } + } + + private void saveLikes(User user, Instance... instances) { + for (Instance instance : instances) { + likesRepository.save(new Likes(user, instance)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/myChallenge/service/MyChallengeFacadeTest.java b/src/test/java/com/genius/gitget/challenge/myChallenge/service/MyChallengeFacadeTest.java new file mode 100644 index 00000000..d75cda1a --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/myChallenge/service/MyChallengeFacadeTest.java @@ -0,0 +1,287 @@ +package com.genius.gitget.challenge.myChallenge.service; + +import static com.genius.gitget.challenge.participant.domain.JoinResult.FAIL; +import static com.genius.gitget.challenge.participant.domain.JoinResult.SUCCESS; +import static com.genius.gitget.challenge.participant.domain.RewardStatus.NO; +import static com.genius.gitget.challenge.participant.domain.RewardStatus.YES; +import static com.genius.gitget.store.item.domain.ItemCategory.CERTIFICATION_PASSER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.repository.CertificationRepository; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.myChallenge.dto.ActivatedResponse; +import com.genius.gitget.challenge.myChallenge.dto.DoneResponse; +import com.genius.gitget.challenge.myChallenge.dto.PreActivityResponse; +import com.genius.gitget.challenge.myChallenge.dto.RewardRequest; +import com.genius.gitget.challenge.myChallenge.facade.MyChallengeFacade; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.repository.OrdersRepository; +import com.genius.gitget.util.certification.CertificationFactory; +import com.genius.gitget.util.instance.InstanceFactory; +import com.genius.gitget.util.participant.ParticipantFactory; +import com.genius.gitget.util.store.StoreFactory; +import com.genius.gitget.util.user.UserFactory; +import java.time.LocalDate; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +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.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class MyChallengeFacadeTest { + private LocalDate localDate = LocalDate.now(); + private User user; + private Instance instance1; + private Instance instance2; + private Instance instance3; + + @Autowired + private MyChallengeFacade myChallengeFacade; + @Autowired + private UserRepository userRepository; + @Autowired + private InstanceRepository instanceRepository; + @Autowired + private ParticipantRepository participantRepository; + @Autowired + private CertificationRepository certificationRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private OrdersRepository ordersRepository; + + @BeforeEach + void setup() { + user = userRepository.save(UserFactory.createUser()); + } + + + @Nested + @DisplayName("시작 전 인스턴스들을 조회할 때") + class context_inquiry_preActivity_instances { + @BeforeEach + void setup() { + instance1 = instanceRepository.save(InstanceFactory.createPreActivity(10)); + instance2 = instanceRepository.save(InstanceFactory.createPreActivity(10)); + instance3 = instanceRepository.save(InstanceFactory.createPreActivity(10)); + } + + @Nested + @DisplayName("참여한 인스턴스 중 시작 전인 인스턴스들이 있을 때") + class describe_joined_preActivity_instance { + @BeforeEach + void setup() { + participantRepository.save(ParticipantFactory.createPreActivity(user, instance1)); + participantRepository.save(ParticipantFactory.createPreActivity(user, instance2)); + participantRepository.save(ParticipantFactory.createPreActivity(user, instance3)); + } + + @Test + @DisplayName("인스턴스 목록들을 조회할 수 있다.") + public void it_returns_instance_list() { + List preActivityInstances = myChallengeFacade.getPreActivityInstances(user, + localDate); + assertThat(preActivityInstances.size()).isEqualTo(3); + } + } + } + + @Nested + @DisplayName("진행 중인 인스턴스 조회 시") + class context_inquiry_activity_instances { + Participant participant; + + @BeforeEach + void setup() { + instance1 = instanceRepository.save(InstanceFactory.createActivity(10)); + participant = participantRepository.save(ParticipantFactory.createPreActivity(user, instance1)); + } + + @Nested + @DisplayName("패스 아이템 사용 여부 조회할 때") + class describe_check_pass_item { + Item item; + Orders orders; + + @BeforeEach + void setup() { + item = itemRepository.findAllByCategory(CERTIFICATION_PASSER).get(0); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, CERTIFICATION_PASSER, 3)); + } + + @Test + @DisplayName("인증 정보가 DB에 저장되어 있지 않다면 사용 가능 여부가 true여야 한다.") + public void it_return_true_when_certification_not_saved_DB() { + List activatedInstances = myChallengeFacade.getActivatedInstances(user, localDate); + for (ActivatedResponse activatedInstance : activatedInstances) { + assertThat(activatedInstance.isCanUsePassItem()).isTrue(); + } + } + + @Test + @DisplayName("인증 정보가 NOT_YET 이라면 사용 가능 여부가 true여야 한다.") + public void it_return_true_when_certification_NOT_YET() { + certificationRepository.save(CertificationFactory.createNotYet(participant, localDate)); + + List activatedInstances = myChallengeFacade.getActivatedInstances(user, localDate); + for (ActivatedResponse activatedInstance : activatedInstances) { + assertThat(activatedInstance.isCanUsePassItem()).isTrue(); + } + } + + @ParameterizedTest + @DisplayName("인증 정보가 PASSED 혹은 CERTIFICATED라면 사용 가능 여부가 false여야 한다.") + @EnumSource(mode = Mode.INCLUDE, names = {"PASSED", "CERTIFICATED"}) + public void it_return_false_when_certification_PASSED_or_CERTIFICATED(CertificateStatus certificateStatus) { + certificationRepository.save(CertificationFactory.create(certificateStatus, localDate, participant)); + + List activatedInstances = myChallengeFacade.getActivatedInstances(user, localDate); + for (ActivatedResponse activatedInstance : activatedInstances) { + assertThat(activatedInstance.isCanUsePassItem()).isFalse(); + } + } + } + } + + @Nested + @DisplayName("완료된 인스턴스 조회 시") + class context_inquiry_done_instances { + Participant participant; + + @BeforeEach + void setup() { + instance1 = instanceRepository.save(InstanceFactory.createDone(10)); + } + + @Nested + @DisplayName("아직 보상받지 않은 인스턴스가 있을 때") + class describe_not_rewarded_challenge_exist { + @BeforeEach + void setup() { + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance1, SUCCESS, NO)); + } + + @Test + @DisplayName("실패한 챌린지라면 획득 포인트는 0이어야하고, 달성률 정보를 전달해야 한다.") + public void it_returns_achievementRate() { + List doneInstances = myChallengeFacade.getDoneInstances(user, localDate); + for (DoneResponse doneInstance : doneInstances) { + assertThat(doneInstance.getRewardedPoints()).isEqualTo(0); + assertThat(doneInstance.getAchievementRate()).isEqualTo(0.0); + } + } + + @Test + @DisplayName("성공한 챌린지라면 보상 가능 여부가 true어야 한다.") + public void it_return_true_when_success_instances() { + List doneInstances = myChallengeFacade.getDoneInstances(user, localDate); + for (DoneResponse doneInstance : doneInstances) { + assertThat(doneInstance.isCanGetReward()).isTrue(); + } + } + } + + @Nested + @DisplayName("보상이 완료된 인스턴스가 있을 때") + class describe_rewarded_challenge_exist { + @BeforeEach + void setup() { + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance1, SUCCESS, YES)); + } + + @Test + @DisplayName("획득 포인트와 달성률에 대한 정보를 전달해야 한다.") + public void it_returns_point_and_achievementRate() { + List doneInstances = myChallengeFacade.getDoneInstances(user, localDate); + DoneResponse doneResponse = doneInstances.get(0); + assertThat(doneResponse.getRewardedPoints()).isEqualTo(participant.getRewardPoints()); + } + } + } + + @Nested + @DisplayName("챌린지 보상을 받을 때") + class context_ { + Participant participant; + + @BeforeEach + void setup() { + instance1 = instanceRepository.save(InstanceFactory.createDone(10)); + } + + @Nested + @DisplayName("보상을 받을 수 있는 조건인지 확인했을 때") + class describe_validate_reward_condition { + @Test + @DisplayName("JoinResult가 SUCCESS가 아니라면 CAN_NOT_GET_REWARDS 예외가 발생해야 한다.") + public void it_throws_CAN_NOT_GET_REWARDS_exception() { + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance1, FAIL, NO)); + assertThatThrownBy(() -> myChallengeFacade.getRewards( + RewardRequest.of(user.getId(), instance1.getId(), localDate))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.CAN_NOT_GET_REWARDS.getMessage()); + } + + @Test + @DisplayName("RewardStatus가 YES라면 ALREADY_REWARDED 예외가 발생해야 한다.") + public void it_throws_ALREADY_REWARDED_exception() { + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance1, SUCCESS, YES) + ); + assertThatThrownBy(() -> myChallengeFacade.getRewards( + RewardRequest.of(user.getId(), instance1.getId(), localDate) + )) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.ALREADY_REWARDED.getMessage()); + } + } + + @Nested + @DisplayName("보상을 받을 수 있고, 보상을 받았을 때") + class describe_get_rewards { + @BeforeEach + void setup() { + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance1, SUCCESS, NO) + ); + myChallengeFacade.getRewards(RewardRequest.of(user.getId(), instance1.getId(), localDate)); + } + + @Test + @DisplayName("participant의 RewardStatus가 YES가 되어야 한다.") + public void it_change_rewardStatus_YES() { + assertThat(participant.getRewardStatus()).isEqualTo(YES); + } + + @Test + @DisplayName("user의 point의 값이 갱신되어야 한다.") + public void it_change_user_point() { + assertThat(user.getPoint()).isEqualTo(participant.getRewardPoints()); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/participant/service/ParticipantServiceTest.java b/src/test/java/com/genius/gitget/challenge/participant/service/ParticipantServiceTest.java new file mode 100644 index 00000000..d1d9a38a --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/participant/service/ParticipantServiceTest.java @@ -0,0 +1,186 @@ +package com.genius.gitget.challenge.participant.service; + +import static com.genius.gitget.challenge.instance.domain.Progress.ACTIVITY; +import static com.genius.gitget.challenge.instance.domain.Progress.DONE; +import static com.genius.gitget.challenge.instance.domain.Progress.PREACTIVITY; +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ParticipantServiceTest { + @Autowired + ParticipantService participantService; + @Autowired + UserRepository userRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + ParticipantRepository participantRepository; + + + @Test + @DisplayName("userId와 instanceId를 통해 저장되어 있는 ParticipantInfo를 받아올 수 있다.") + public void should_getParticipantInfo_when_passUserIdAndInstanceId() { + //given + User savedUser = getSavedUser(); + Instance savedInstance = getSavedInstance(PREACTIVITY); + getSavedParticipant(savedUser, savedInstance); + + //when + Participant participant = participantService.findByJoinInfo(savedUser.getId(), + savedInstance.getId()); + + //then + assertThat(participant.getUser().getId()).isEqualTo(savedUser.getId()); + assertThat(participant.getInstance().getId()).isEqualTo(savedInstance.getId()); + } + + @Test + @DisplayName("Participant를 Participant의 PK를 통해 찾을 수 있다.") + public void should_getParticipant_when_passPK() { + //given + User savedUser = getSavedUser(); + Instance savedInstance = getSavedInstance(PREACTIVITY); + Participant participant = getSavedParticipant(savedUser, savedInstance); + + //when + Participant foundParticipant = participantService.findById(participant.getId()); + + //then + assertThat(foundParticipant.getId()).isEqualTo(participant.getId()); + assertThat(foundParticipant.getJoinStatus()).isEqualTo(participant.getJoinStatus()); + assertThat(foundParticipant.getUser()).isEqualTo(savedUser); + assertThat(foundParticipant.getInstance()).isEqualTo(savedInstance); + } + + @Test + @DisplayName("Participant들 중 Progress(진행 상황)과 사용자 정보 조건에 맞는 정보들을 불러올 수 있다.") + public void should_returnList_when_passProgress() { + //given + User user = getSavedUser(); + Instance instance1 = getSavedInstance(PREACTIVITY); + Instance instance2 = getSavedInstance(PREACTIVITY); + Instance instance3 = getSavedInstance(ACTIVITY); + Participant participant1 = getSavedParticipant(user, instance1); + Participant participant2 = getSavedParticipant(user, instance2); + Participant participant3 = getSavedParticipant(user, instance3); + + //when + List participants = participantService.findJoinedByProgress(user.getId(), PREACTIVITY); + + //then + assertThat(participants.size()).isEqualTo(2); + } + + @Test + @DisplayName("Participant들 중, ACTIVITY에 해당하는 정보들을 불러올 수 있다.") + public void should_return_activity_participants() { + //given + User user = getSavedUser(); + Instance instance1 = getSavedInstance(PREACTIVITY); + Instance instance2 = getSavedInstance(PREACTIVITY); + Instance instance3 = getSavedInstance(ACTIVITY); + Participant participant1 = getSavedParticipant(user, instance1); + Participant participant2 = getSavedParticipant(user, instance2); + Participant participant3 = getSavedParticipant(user, instance3); + + //when + List participants = participantService.findJoinedByProgress(user.getId(), ACTIVITY); + + //then + assertThat(participants.size()).isEqualTo(1); + } + + @Test + @DisplayName("Participants들 중, 진행 중이지만 도중 참가 취소로 인해 실패한 챌린지 리스트를 불러올 수 있다.") + public void should_return_quit_instances_when_activity() { + //given + User user = getSavedUser(); + Instance instance1 = getSavedInstance(PREACTIVITY); + Instance instance2 = getSavedInstance(ACTIVITY); + Instance instance3 = getSavedInstance(ACTIVITY); + Participant participant3 = getSavedParticipant(user, instance1); + Participant participant2 = getSavedParticipant(user, instance2); + Participant participant1 = getSavedParticipant(user, instance3, JoinStatus.NO); + + //when + List participants = participantService.findDoneInstances(user.getId()); + + //then + assertThat(participants.size()).isEqualTo(1); + } + + @Test + @DisplayName("Participants들 중, 성공한 챌린지 리스트들을 불러올 수 있다.") + public void should_return_success_instances() { + //given + User user = getSavedUser(); + Instance instance1 = getSavedInstance(ACTIVITY); + Instance instance2 = getSavedInstance(DONE); + Instance instance3 = getSavedInstance(DONE); + Participant participant3 = getSavedParticipant(user, instance1); + Participant participant2 = getSavedParticipant(user, instance2); + Participant participant1 = getSavedParticipant(user, instance3); + + //when + List participants = participantService.findDoneInstances(user.getId()); + + //then + assertThat(participants.size()).isEqualTo(2); + } + + + private User getSavedUser() { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("identifier") + .information("information") + .tags("BE,FE") + .build() + ); + } + + private Instance getSavedInstance(Progress progress) { + return instanceRepository.save( + Instance.builder() + .progress(progress) + .build() + ); + } + + private Participant getSavedParticipant(User user, Instance instance) { + Participant participant = Participant.builder() + .joinStatus(JoinStatus.YES) + .build(); + participant.setUserAndInstance(user, instance); + return participantRepository.save(participant); + } + + private Participant getSavedParticipant(User user, Instance instance, JoinStatus joinStatus) { + Participant participant = Participant.builder() + .joinStatus(joinStatus) + .build(); + participant.setUserAndInstance(user, instance); + return participantRepository.save(participant); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/user/controller/UserControllerTest.java b/src/test/java/com/genius/gitget/challenge/user/controller/UserControllerTest.java new file mode 100644 index 00000000..1bf835e0 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/user/controller/UserControllerTest.java @@ -0,0 +1,86 @@ +package com.genius.gitget.challenge.user.controller; + +import static com.genius.gitget.global.util.exception.ErrorCode.DUPLICATED_NICKNAME; +import static com.genius.gitget.global.util.exception.SuccessCode.SUCCESS; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +@Slf4j +@ActiveProfiles({"file"}) +class UserControllerTest { + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + UserRepository userRepository; + @Autowired + ObjectMapper objectMapper; + @Value("${file.upload.path}") + private String testUploadPath; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + @DisplayName("사용자의 닉네임이 중복된다면 400번대를 반환한다.") + public void should_return4XX_when_nicknameDuplicated() throws Exception { + //given + User user = getSavedUser(); + + //when & then + mockMvc.perform(get("/api/auth/check-nickname?nickname=" + user.getNickname())) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.resultCode").value(DUPLICATED_NICKNAME.getStatus().value())) + .andExpect(jsonPath("$.message").value(DUPLICATED_NICKNAME.getMessage())); + } + + @Test + @DisplayName("사용자의 닉네임이 중복되지 않는다면 200번대를 반환한다.") + public void should_return2XX_when_nicknameNotDuplicated() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname?nickname=" + "nickname")) + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.resultCode").value(SUCCESS.getStatus().value())) + .andExpect(jsonPath("$.message").value(SUCCESS.getMessage())); + } + + + private User getSavedUser() { + return userRepository.save(User.builder() + .identifier("identifier") + .role(Role.USER) + .information("information") + .tags("interest1,interest2") + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .build()); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/user/domain/UserTest.java b/src/test/java/com/genius/gitget/challenge/user/domain/UserTest.java new file mode 100644 index 00000000..954705de --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/user/domain/UserTest.java @@ -0,0 +1,97 @@ +package com.genius.gitget.challenge.user.domain; + +import static com.genius.gitget.challenge.user.domain.Role.ADMIN; +import static com.genius.gitget.challenge.user.domain.Role.USER; +import static com.genius.gitget.global.security.constants.ProviderInfo.GOOGLE; +import static com.genius.gitget.global.security.constants.ProviderInfo.NAVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.xmlunit.util.Linqy.count; + +import com.genius.gitget.challenge.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +public class UserTest { + + @Autowired + private UserRepository userRepository; + + @Test + public void 사용자_추가() { + User user = User.builder().identifier("neo5188@gmail.com") + .providerInfo(NAVER) + .nickname("kimdozzi") + .information("백엔드") + .tags("운동") + .role(ADMIN) + .build(); + + User savedUser = userRepository.save(user); + assertThat(savedUser.getId()).isEqualTo(user.getId()); + } + + @Test + public void 사용자_목록_조회() { + User savedUser1 = userRepository.save(userA()); + User savedUser2 = userRepository.save(userB()); + + List users = userRepository.findAll(); + assertThat(count(users)).isEqualTo(3); + assertThat(savedUser1).isNotSameAs(savedUser2); + } + + @Test + public void 사용자_정보_수정() { + User savedUser1 = userRepository.save(userA()); + + String nickName = "zzanggu"; + String information = "This is updated info !!"; + String interest = "This is interest!!"; + + savedUser1.updateUser(nickName, information, interest); + User savedUser = userRepository.save(savedUser1); + + assertThat(nickName).isEqualTo(savedUser.getNickname()); + + System.out.println("savedUser.getNickname() = " + savedUser.getNickname()); + System.out.println("savedUser.getIdentifier() = " + savedUser.getIdentifier()); + + } + + @Test + public void 사용자_삭제() { + User savedUser1 = userRepository.save(userA()); + User savedUser2 = userRepository.save(userB()); + + userRepository.deleteAll(); + + List users = userRepository.findAll(); + + assertThat(users.size()).isEqualTo(0); + } + + private User userA() { + return User.builder().identifier("neo5188@gmail.com") + .providerInfo(NAVER) + .nickname("kimdozzi") + .information("백엔드") + .tags("운동") + .role(ADMIN) + .build(); + } + + private User userB() { + return User.builder().identifier("ssang23@naver.com") + .providerInfo(GOOGLE) + .nickname("SEONG") + .information("프론트엔드") + .tags("영화") + .role(USER) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/user/repository/UserRepositoryTest.java b/src/test/java/com/genius/gitget/challenge/user/repository/UserRepositoryTest.java new file mode 100644 index 00000000..30a1a8ff --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/user/repository/UserRepositoryTest.java @@ -0,0 +1,84 @@ +package com.genius.gitget.challenge.user.repository; + +import static com.genius.gitget.global.security.constants.ProviderInfo.GITHUB; +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.security.constants.ProviderInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class UserRepositoryTest { + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("email을 통해 저장한 User 객체 찾은 후, 검증") + public void email을_통해_저장한_User_객체를_찾을수있다() { + //given + String email = "test@naver.com"; + ProviderInfo providerInfo = ProviderInfo.GOOGLE; + String nickname = "test_nickname"; + User user = getUnsavedUser(email, providerInfo, nickname); + + //when + User savedUser = userRepository.save(user); + User foundUser = userRepository.findByIdentifier(email).get(); + + //then + assertThat(savedUser.getId()).isEqualTo(foundUser.getId()); + assertThat(savedUser.getIdentifier()).isEqualTo(foundUser.getIdentifier()); + assertThat(savedUser.getProviderInfo()).isEqualTo(foundUser.getProviderInfo()); + assertThat(savedUser.getNickname()).isEqualTo(foundUser.getNickname()); + } + + @Test + @DisplayName("User 객체 저장 테스트") + public void email_provider를_통해_저장한_User_객체를_찾을수있다() { + //given + String email = "test@naver.com"; + ProviderInfo providerInfo = ProviderInfo.GOOGLE; + String nickname = "test_nickname"; + User user = getUnsavedUser(email, providerInfo, nickname); + + //when + User savedUser = userRepository.save(user); + User foundUser = userRepository.findByOAuthInfo(email, providerInfo).get(); + + //then + assertThat(savedUser.getId()).isEqualTo(foundUser.getId()); + assertThat(savedUser.getIdentifier()).isEqualTo(foundUser.getIdentifier()); + assertThat(savedUser.getProviderInfo()).isEqualTo(foundUser.getProviderInfo()); + assertThat(savedUser.getNickname()).isEqualTo(foundUser.getNickname()); + } + + @Test + @DisplayName("User nickname 중복 방지 테스트") + public void checkNicknameDuplicate() { + //given + User user1 = getUnsavedUser("SSung023", GITHUB, "nickname"); + + //when + User savedUser = userRepository.save(user1); + User nickname = userRepository.findByNickname("nickname").get(); + + //then + assertThat(nickname).isEqualTo(savedUser); + } + + + private User getUnsavedUser(String identifier, ProviderInfo providerInfo, String nickname) { + return User.builder() + .identifier(identifier) + .providerInfo(providerInfo) + .role(Role.USER) + .nickname(nickname) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/challenge/user/service/UserFacadeTest.java b/src/test/java/com/genius/gitget/challenge/user/service/UserFacadeTest.java new file mode 100644 index 00000000..5d918cb9 --- /dev/null +++ b/src/test/java/com/genius/gitget/challenge/user/service/UserFacadeTest.java @@ -0,0 +1,212 @@ +package com.genius.gitget.challenge.user.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.ALREADY_REGISTERED; +import static com.genius.gitget.global.util.exception.ErrorCode.DUPLICATED_NICKNAME; +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_AUTHENTICATED_USER; +import static com.genius.gitget.store.item.domain.ItemCategory.PROFILE_FRAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.dto.SignupRequest; +import com.genius.gitget.challenge.user.facade.UserFacade; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.dto.AuthResponse; +import com.genius.gitget.global.security.dto.SignupResponse; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.EquipStatus; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.repository.OrdersRepository; +import com.genius.gitget.util.store.StoreFactory; +import com.genius.gitget.util.user.UserFactory; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +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 org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Slf4j +class UserFacadeTest { + @Autowired + private UserRepository userRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private OrdersRepository ordersRepository; + @Autowired + private UserFacade userFacade; + + @Value("${github.yeon-githubId}") + private String githubId; + + private User user; + + @Nested + @DisplayName("회원 가입 시도 시") + class context_try_register { + @Nested + @DisplayName("사용자의 정보 확인했을 때") + class describe_check_user_info { + @Test + @DisplayName("어드민 깃허브 계정에 해당하는 경우, ADMIN으로 설정된다.") + public void it_set_role_admin() { + userRepository.save(UserFactory.createUnregistered(githubId)); + SignupResponse signupResponse = userFacade.signup(getSignupRequest(githubId)); + + Optional optionalUser = userRepository.findById(signupResponse.userId()); + assertThat(optionalUser).isPresent(); + assertThat(optionalUser.get().getId()).isEqualTo(signupResponse.userId()); + assertThat(optionalUser.get().getRole()).isEqualTo(Role.ADMIN); + } + + @Test + @DisplayName("어드민 깃허브 계정에 해당하지 않는 경우, USER로 설정된다.") + public void it_set_role_user() { + String identifier = "identifier"; + userRepository.save(UserFactory.createUnregistered(identifier)); + SignupResponse signupResponse = userFacade.signup(getSignupRequest(identifier)); + + Optional optionalUser = userRepository.findById(signupResponse.userId()); + assertThat(optionalUser).isPresent(); + assertThat(optionalUser.get().getId()).isEqualTo(signupResponse.userId()); + assertThat(optionalUser.get().getRole()).isEqualTo(Role.USER); + } + + @Test + @DisplayName("이미 회원가입된 사용자인 경우 ALREADY_REGISTERED 예외가 발생한다.") + public void it_throw_ALREADY_REGISTERED_exception() { + userRepository.save(UserFactory.createUnregistered(githubId)); + SignupRequest signupRequest = getSignupRequest(githubId); + userFacade.signup(signupRequest); + + assertThatThrownBy(() -> userFacade.signup(signupRequest)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ALREADY_REGISTERED.getMessage()); + } + + SignupRequest getSignupRequest(String identifier) { + return SignupRequest.builder() + .identifier(identifier) + .information("information") + .interest(List.of("java", "BE")) + .nickname("nickname") + .build(); + } + } + } + + @Nested + @DisplayName("사용자의 닉네임 중복 확인 시") + class context_check_nickname_duplication { + String nickname = "nickname"; + + @Nested + @DisplayName("닉네임을 전달했을 때") + class describe_pass_nickname { + @Test + @DisplayName("닉네임이 기존에 존재하지 않는다면, 예외가 발생하지 않는다.") + public void it_not_throw_exception_nickname_not_exist() { + assertThatNoException().isThrownBy( + () -> userFacade.isNicknameDuplicate(nickname) + ); + } + + @Test + @DisplayName("닉네임이 기존에 존재한다면, DUPLICATED_NICKNAME 예외가 발생한다.") + public void it_throw_DUPLICATED_NICKNAME_exception_nickname_exist() { + user = userRepository.save(UserFactory.createUnregistered(githubId)); + user.updateUserInformation(nickname, "information"); + + assertThatThrownBy(() -> userFacade.isNicknameDuplicate(nickname)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(DUPLICATED_NICKNAME.getMessage()); + } + } + } + + @Nested + @DisplayName("토큰 발급 이후 사용자의 정보 조회 시") + class context_inquiry_user_after_issue_token { + @Nested + @DisplayName("사용자의 identifier 전달 시") + class describe_pass_identifier { + Item item; + Orders orders; + + @BeforeEach + void setup() { + user = userRepository.save(UserFactory.createByInfo(githubId, Role.USER)); + item = itemRepository.save(StoreFactory.createItem(PROFILE_FRAME)); + } + + @Test + @DisplayName("identifier에 해당하는 사용자가 없으면 MEMBER_NOT_FOUND 예외가 발생한다.") + public void it_throw_MEMBER_NOT_FOUND_exception() { + assertThatThrownBy(() -> userFacade.getUserAuthInfo("identifier")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("사용자가 프로필 프레임을 장착하고 있지 않을 때, 프레임 정보에 null이 담긴다.") + public void it_return_null_when_not_use_frame() { + AuthResponse authResponse = userFacade.getUserAuthInfo(githubId); + assertThat(authResponse.role()).isEqualTo(Role.USER); + assertThat(authResponse.frameId()).isNull(); + } + + @Test + @DisplayName("사용자가 프로필 프레임을 장착하고 있을 때, 프로필 정보에 아이템의 PK가 담긴다.") + public void it_return_itemPK_when_use_frame() { + orders = ordersRepository.save(StoreFactory.createOrders(user, item, PROFILE_FRAME, 3)); + orders.updateEquipStatus(EquipStatus.IN_USE); + + AuthResponse authResponse = userFacade.getUserAuthInfo(githubId); + assertThat(authResponse.role()).isEqualTo(Role.USER); + assertThat(authResponse.frameId()).isEqualTo(item.getIdentifier()); + } + } + } + + @Nested + @DisplayName("인증 가능한 사용자 조회 시") + class context_inquiry_auth_user_info { + @Nested + @DisplayName("사용자의 identifier 전달 시") + class describe_pass_identifier { + @Test + @DisplayName("회원 가입이 완료된 사용자라면 User 엔티티를 반환한다.") + public void it_return_user_when_registered() { + user = userRepository.save(UserFactory.createByInfo(githubId, Role.USER)); + + User authUser = userFacade.getAuthUser(user.getIdentifier()); + + assertThat(authUser.getId()).isEqualTo(user.getId()); + assertThat(authUser.getIdentifier()).isEqualTo(user.getIdentifier()); + } + + @Test + @DisplayName("회원 가입이 안 된 사용자라면 NOT_AUTHENTICATED_USER 예외가 발생한다.") + public void it_throws_NOT_AUTHENTICATED_USER_exception() { + user = userRepository.save(UserFactory.createUnregistered(githubId)); + + assertThatThrownBy(() -> userFacade.getAuthUser(githubId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(NOT_AUTHENTICATED_USER.getMessage()); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/file/domain/FilesTest.java b/src/test/java/com/genius/gitget/global/file/domain/FilesTest.java new file mode 100644 index 00000000..f45070d2 --- /dev/null +++ b/src/test/java/com/genius/gitget/global/file/domain/FilesTest.java @@ -0,0 +1,41 @@ +package com.genius.gitget.global.file.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.global.file.dto.UpdateDTO; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class FilesTest { + + @Test + @DisplayName("파일이 수정되어야 할 때, UpdateDTO를 전달하여 정보를 수정할 수 있다.") + public void should_updateFiles_when_passUpdateDTO() { + //given + Files files = Files.builder() + .fileType(FileType.INSTANCE) + .originalFilename("originalFilename") + .savedFilename("savedFilename") + .fileURI("source") + .build(); + + UpdateDTO updateDTO = UpdateDTO.builder() + .savedFilename("new savedFilename") + .originalFilename("new originalFilename") + .fileURI("new source") + .build(); + + //when + files.updateFiles(updateDTO); + + //then + assertThat(files.getOriginalFilename()).isEqualTo(updateDTO.originalFilename()); + assertThat(files.getSavedFilename()).isEqualTo(updateDTO.savedFilename()); + assertThat(files.getFileURI()).isEqualTo(updateDTO.fileURI()); + + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/file/repository/FilesRepositoryTest.java b/src/test/java/com/genius/gitget/global/file/repository/FilesRepositoryTest.java new file mode 100644 index 00000000..3a3e581f --- /dev/null +++ b/src/test/java/com/genius/gitget/global/file/repository/FilesRepositoryTest.java @@ -0,0 +1,40 @@ +package com.genius.gitget.global.file.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class FilesRepositoryTest { + @Autowired + private FilesRepository filesRepository; + + @Test + @DisplayName("File 엔티티 저장한 후, PK를 통해 해당 엔티티를 찾을 수 있다.") + public void fileSaveTest() { + //given + Files files = Files.builder() + .fileType(FileType.TOPIC) + .savedFilename("saved file name") + .originalFilename("original file name") + .fileURI("file uri") + .build(); + + //when + Files savedFiles = filesRepository.save(files); + + //then + assertThat(savedFiles.getFileType()).isEqualTo(files.getFileType()); + assertThat(savedFiles.getSavedFilename()).isEqualTo(files.getSavedFilename()); + assertThat(savedFiles.getOriginalFilename()).isEqualTo(files.getOriginalFilename()); + assertThat(savedFiles.getFileURI()).isEqualTo(files.getFileURI()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/file/service/FileUtilTest.java b/src/test/java/com/genius/gitget/global/file/service/FileUtilTest.java new file mode 100644 index 00000000..195217f9 --- /dev/null +++ b/src/test/java/com/genius/gitget/global/file/service/FileUtilTest.java @@ -0,0 +1,152 @@ +package com.genius.gitget.global.file.service; + +import static com.genius.gitget.global.file.domain.FileType.INSTANCE; +import static com.genius.gitget.global.file.domain.FileType.TOPIC; +import static com.genius.gitget.global.util.exception.ErrorCode.INVALID_FILE_NAME; +import static com.genius.gitget.global.util.exception.ErrorCode.NOT_SUPPORTED_EXTENSION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.global.file.domain.FileType; +import com.genius.gitget.global.file.domain.Files; +import com.genius.gitget.global.file.dto.CopyDTO; +import com.genius.gitget.global.file.dto.FileDTO; +import com.genius.gitget.global.util.exception.BusinessException; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@SpringBootTest +@Transactional +@ActiveProfiles({"file"}) +class FileUtilTest { + @Autowired + private FileUtil fileUtil; + @Autowired + private FilesManager filesManager; + @Value("${file.upload.path}") + private String UPLOAD_PATH; + + @Test + @DisplayName("file을 전달받았을 때, originFilename가 null일 때 예외를 발생해야 한다.") + public void should_throwException_when_originFilenameIsNull() { + //given + MultipartFile multipartFile = getTestMultiPartFile(null); + + //when&then + assertThatThrownBy(() -> fileUtil.validateFile(multipartFile)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INVALID_FILE_NAME.getMessage()); + } + + @Test + @DisplayName("file을 전달받았을 때, originFilename이 비어있다면 예외를 발생해야 한다.") + public void should_throwException_when_originFilenameIsBlank() { + //given + MultipartFile multipartFile = getTestMultiPartFile(""); + + //when&then + assertThatThrownBy(() -> fileUtil.validateFile(multipartFile)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INVALID_FILE_NAME.getMessage()); + } + + @Test + @DisplayName("file을 전달받았을 때, 지원하는 확장자가 아니라면 예외를 발생해야 한다.") + public void should_throwException_when_notSupportedExtension() { + //given + MultipartFile multipartFile = getTestMultiPartFile("sky.pdf"); + + //when&then + assertThatThrownBy(() -> fileUtil.validateFile(multipartFile)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(NOT_SUPPORTED_EXTENSION.getMessage()); + } + + @Test + @DisplayName("유효한 file을 전달받았을 때, 반환받은 File 객체에는 upload path가 포함되어 있어야 한다.") + public void should_returnTargetFileInstance_when_passValidFile() { + //given + MultipartFile multipartFile = getTestMultiPartFile("sky.png"); + + //when + FileDTO fileDTO = fileUtil.getFileDTO(multipartFile, FileType.PROFILE, UPLOAD_PATH); + + //then + assertThat(fileDTO.fileURI()).contains(UPLOAD_PATH); + } + + @Test + @DisplayName("기존의 파일을 복사하려고 할 때, 복사에 필요한 정보들을 추출하여 전달할 수 있다.") + public void should_passInformation_when_tryToCopy() { + //given + Files files = Files.builder() + .originalFilename("original file name.png") + .savedFilename("saved file name") + .fileURI("file URI") + .fileType(TOPIC) + .build(); + + //when + CopyDTO copyDTO = fileUtil.getCopyInfo(files, INSTANCE, UPLOAD_PATH); + + //then + assertThat(copyDTO.fileType()).isEqualTo(INSTANCE); + assertThat(copyDTO.fileURI()).contains(UPLOAD_PATH); + } + + + private MultipartFile getTestMultiPartFile(String originalFilename) { + return new MultipartFile() { + @Override + public String getName() { + return "image"; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return null; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public long getSize() { + return 0; + } + + @Override + public byte[] getBytes() throws IOException { + return new byte[0]; + } + + @Override + public InputStream getInputStream() throws IOException { + return null; + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + + } + }; + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/file/service/FilesServiceTest.java b/src/test/java/com/genius/gitget/global/file/service/FilesServiceTest.java new file mode 100644 index 00000000..4bf2b1af --- /dev/null +++ b/src/test/java/com/genius/gitget/global/file/service/FilesServiceTest.java @@ -0,0 +1,23 @@ +package com.genius.gitget.global.file.service; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class FilesServiceTest { +// @Autowired +// private FilesService filesService; +// +// @Test +// @DisplayName("") +// public void (){ +// //given +// +// //when +// +// //then +// +// } + +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/security/config/SecurityConfigTest.java b/src/test/java/com/genius/gitget/global/security/config/SecurityConfigTest.java new file mode 100644 index 00000000..412d66cf --- /dev/null +++ b/src/test/java/com/genius/gitget/global/security/config/SecurityConfigTest.java @@ -0,0 +1,76 @@ +package com.genius.gitget.global.security.config; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +class SecurityConfigTest { + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + } + + @Test + @DisplayName("swagger에 해당하는 URI에 대해서는 2xx 응답이 발생해야 한다.") + public void should_status2xx_when_swaggerUri() throws Exception { + //given + mockMvc.perform(get("/swagger-ui/index.html")) + .andExpect(status().is2xxSuccessful()); + } + + @Test + @DisplayName("permitAll에 해당하지 않는 URI에 대해서는 4xx 응답이 발생해야 한다.") + public void should_status2xx_when_uriIsPermitAll() throws Exception { + //given + + //when&then + mockMvc.perform(get("/api/test")) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("Admin API에 대해서 Role이 Admin일 때에는 2xx 응답이 발생해야 한다.") + @WithMockCustomUser(role = Role.ADMIN) + public void should_status2xx_when_roleIsAdmin() throws Exception { + //given + + //when & then + mockMvc.perform(get("/api/admin/topic") + .headers(tokenTestUtil.createAccessHeaders())) + .andExpect(status().is2xxSuccessful()); + } + + @Test + @DisplayName("Admin API에 대해서 Role이 Admin이 아닐 때에는 4xx 응답이 발생해야 한다.") + @WithMockCustomUser(role = Role.USER) + public void should_status4xx_when_roleNotAdmin() throws Exception { + mockMvc.perform(get("/api/admin/topic") + .headers(tokenTestUtil.createAccessHeaders())) + .andExpect(status().is4xxClientError()); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/security/controller/AuthControllerTest.java b/src/test/java/com/genius/gitget/global/security/controller/AuthControllerTest.java new file mode 100644 index 00000000..b2474504 --- /dev/null +++ b/src/test/java/com/genius/gitget/global/security/controller/AuthControllerTest.java @@ -0,0 +1,33 @@ +package com.genius.gitget.global.security.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.genius.gitget.util.security.TokenTestUtil; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +@Slf4j +public class AuthControllerTest { + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + + @Autowired + TokenTestUtil tokenTestUtil; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } +} diff --git a/src/test/java/com/genius/gitget/global/security/service/JwtFacadeServiceTest.java b/src/test/java/com/genius/gitget/global/security/service/JwtFacadeServiceTest.java new file mode 100644 index 00000000..d365ac80 --- /dev/null +++ b/src/test/java/com/genius/gitget/global/security/service/JwtFacadeServiceTest.java @@ -0,0 +1,274 @@ +package com.genius.gitget.global.security.service; + +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_HEADER; +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_PREFIX; +import static com.genius.gitget.global.security.constants.JwtRule.REFRESH_PREFIX; +import static com.genius.gitget.global.util.exception.ErrorCode.INVALID_JWT; +import static com.genius.gitget.global.util.exception.ErrorCode.JWT_NOT_FOUND_IN_COOKIE; +import static com.genius.gitget.global.util.exception.ErrorCode.JWT_NOT_FOUND_IN_HEADER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.security.domain.Token; +import com.genius.gitget.global.security.domain.UserPrincipal; +import com.genius.gitget.global.security.repository.TokenRepository; +import com.genius.gitget.global.util.exception.BusinessException; +import jakarta.servlet.http.Cookie; +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +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.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Slf4j +@ActiveProfiles({"jwt"}) +class JwtFacadeServiceTest { + User user; + MockHttpServletRequest request; + MockHttpServletResponse response; + + @Autowired + private TokenRepository tokenRepository; + @Autowired + private JwtFacade jwtFacade; + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + user = userRepository.save(User.builder() + .providerInfo(ProviderInfo.GITHUB) + .nickname("nickname") + .identifier("identifier") + .role(Role.USER) + .tags("interest1,interest2") + .information("information") + .build()); + response = new MockHttpServletResponse(); + request = new MockHttpServletRequest(); + } + + @AfterEach + void clearMongo() { + tokenRepository.deleteAll(); + } + + + @Nested + @DisplayName("JWT 생성 시") + class describe_create_jwt { + @Nested + @DisplayName("사용자의 정보를 전달하면") + class context_pass_user_info { + @Test + @DisplayName("Access token을 생성하여 Authorization 헤더에 담는다.") + public void it_returns_headers_that_contain_access() { + String accessToken = jwtFacade.generateAccessToken(response, user); + Collection headerNames = response.getHeaderNames(); + + assertThat(headerNames).contains(ACCESS_HEADER.getValue()); + assertThat(response.getHeader(ACCESS_HEADER.getValue())).contains(accessToken); + } + + @Test + @DisplayName("Refresh token을 생성하여 Cookie에 담는다.") + public void it_returns_cookie_that_contain_refresh() { + String refreshToken = jwtFacade.generateRefreshToken(response, user); + Cookie cookie = response.getCookies()[0]; + + assertThat(cookie.getValue()).isEqualTo(refreshToken); + assertThat(cookie.getSecure()).isTrue(); + assertThat(cookie.getPath()).isEqualTo("/"); + } + } + } + + @Nested + @DisplayName("JWT 유효성 확인 시") + class describe_validate_jwt { + @Nested + @DisplayName("Access token을 전달한 경우") + class context_pass_access { + @Test + @DisplayName("유효기간이 만료되지 않았고, 토큰이 유효하다면 true를 반환한다.") + public void it_returns_true_when_token_not_expired_and_valid() { + String accessToken = jwtFacade.generateAccessToken(response, user); + boolean isAccessValid = jwtFacade.validateAccessToken(accessToken); + assertThat(isAccessValid).isTrue(); + } + + @Test + @DisplayName("토큰이 유효하지 않는다면 BusinessException 예외를 발생한다.") + public void it_throws_BusinessException_when_token_invalid() { + String accessToken = "invalid access token"; + assertThatThrownBy(() -> jwtFacade.validateAccessToken(accessToken)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INVALID_JWT.getMessage()); + } + } + + @Nested + @DisplayName("Refresh token을 전달한 경우") + class context_pass_refresh { + @Test + @DisplayName("토큰이 유효하고, DB에 저장된 토큰과 같다면 true를 반환한다.") + public void it_returns_true_when_token_valid_and_stored_db() { + String refreshToken = jwtFacade.generateRefreshToken(response, user); + boolean isRefreshValid = jwtFacade.validateRefreshToken(refreshToken, user.getIdentifier()); + assertThat(isRefreshValid).isTrue(); + } + + @Test + @DisplayName("토큰이 유효하지 않는다면 BusinessException 예외를 발생한다.") + public void it_throws_BusinessException_when_token_invalid() { + String refreshToken = "invalid refresh token"; + assertThatThrownBy(() -> jwtFacade.validateRefreshToken(refreshToken, user.getIdentifier())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INVALID_JWT.getMessage()); + } + + @Test + @DisplayName("DB에 저장된 토큰과 같지 않다면 false를 반환한다.") + public void it_returns_false_when_not_match_db() { + String refreshToken = jwtFacade.generateRefreshToken(response, user); + tokenRepository.save(new Token(user.getIdentifier(), "invalid refresh token")); + + boolean isRefreshValid = jwtFacade.validateRefreshToken(refreshToken, user.getIdentifier()); + assertThat(isRefreshValid).isFalse(); + } + } + } + + @Nested + @DisplayName("HttpServletRequest로부터") + class describe_from_HttpServletRequest { + @Nested + @DisplayName("access token을 추출하는 경우") + class context_resolve_access { + @Test + @DisplayName("Authorization 헤더에 유효한 토큰이 있는 경우 access token을 반환한다.") + public void it_returns_access_token() { + String accessToken = jwtFacade.generateAccessToken(response, user); + request.addHeader(ACCESS_HEADER.getValue(), ACCESS_PREFIX.getValue() + accessToken); + + String resolvedAccessToken = jwtFacade.resolveAccessToken(request); + assertThat(accessToken).isEqualTo(resolvedAccessToken); + } + + @Test + @DisplayName("Authorization 헤더에 빈 문자열이 있는 경우 BusinessException 예외가 발생한다.") + public void it_throws_BusinessException_when_authorization_is_empty() { + jwtFacade.generateAccessToken(response, user); + request.addHeader(ACCESS_HEADER.getValue(), ""); + assertThatThrownBy(() -> jwtFacade.resolveAccessToken(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(JWT_NOT_FOUND_IN_HEADER.getMessage()); + } + + @Test + @DisplayName("Authorization 헤더가 null인 경우 BusinessException 예외가 발생한다.") + public void it_throws_BusinessException_when_authorization_is_null() { + assertThatThrownBy(() -> jwtFacade.resolveAccessToken(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(JWT_NOT_FOUND_IN_HEADER.getMessage()); + } + } + + @Nested + @DisplayName("refresh token을 추출하는 경우") + class context_resolve_refresh { + @Test + @DisplayName("Cookie에 유효한 토큰이 있는 경우 Refresh token을 반환한다.") + public void it_returns_refresh_token() { + String refreshToken = jwtFacade.generateRefreshToken(response, user); + + request.setCookies(new Cookie(REFRESH_PREFIX.getValue(), refreshToken)); + String resolvedRefreshToken = jwtFacade.resolveRefreshToken(request); + assertThat(refreshToken).isEqualTo(resolvedRefreshToken); + } + + @Test + @DisplayName("Cookie에 refresh 토큰이 없는 경우 BusinessException 예외가 발생한다.") + public void it_throws_businessException_when_token_not_exist() { + assertThatThrownBy(() -> jwtFacade.resolveRefreshToken(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(JWT_NOT_FOUND_IN_COOKIE.getMessage()); + } + } + } + + @Nested + @DisplayName("사용자의 식별자를 확인 시") + class describe_check_user_identifier { + @Nested + @DisplayName("Refresh token을 전달했을 때") + class context_pass_refresh_token { + @Test + @DisplayName("토큰이 유효하다면 사용자의 identifier를 반환한다.") + public void it_returns_identifier_when_refresh_valid() { + String refreshToken = jwtFacade.generateRefreshToken(response, user); + String identifier = jwtFacade.getIdentifierFromRefresh(refreshToken); + assertThat(user.getIdentifier()).isEqualTo(identifier); + } + + @Test + @DisplayName("토큰이 유효하지 않으면 BusinessException 예외가 발생한다.") + public void it_throws_businessException_when_refresh_invalid() { + String invalidRefreshToken = "invalid refresh token"; + assertThatThrownBy(() -> jwtFacade.getIdentifierFromRefresh(invalidRefreshToken)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(INVALID_JWT.getMessage()); + } + } + } + + @Nested + @DisplayName("SecurityContext에 저장할 객체를 받으려 할 때") + class describe_try_to_get_authentication { + @Nested + @DisplayName("Access token을 전달한 경우") + class context_pass_access_token { + @Test + @DisplayName("Authentication를 반환받을 수 있다.") + public void it_returns_identifier() { + String accessToken = jwtFacade.generateAccessToken(response, user); + Authentication authentication = jwtFacade.getAuthentication(accessToken); + + String identifier = ((UserPrincipal) authentication.getPrincipal()).getUser().getIdentifier(); + assertThat(identifier).isEqualTo(user.getIdentifier()); + } + } + } + + @Nested + @DisplayName("로그아웃 요청을 받았을 때") + class describe_logout { + @Nested + @DisplayName("사용자의 식별자 정보를 전달하면") + class context_pass_identifier { + @Test + @DisplayName("cookie를 비우고, DB의 토큰 정보도 삭제한다.") + public void it_clear_cookie_and_db() { + jwtFacade.generateRefreshToken(response, user); + jwtFacade.logout(response, user.getIdentifier()); + + assertThat(tokenRepository.findById(user.getIdentifier())).isNotPresent(); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/security/service/JwtUtilTest.java b/src/test/java/com/genius/gitget/global/security/service/JwtUtilTest.java new file mode 100644 index 00000000..07163db9 --- /dev/null +++ b/src/test/java/com/genius/gitget/global/security/service/JwtUtilTest.java @@ -0,0 +1,33 @@ +package com.genius.gitget.global.security.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.global.security.constants.JwtRule; +import jakarta.servlet.http.Cookie; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@Slf4j +@SpringBootTest +class JwtUtilTest { + @Autowired + private JwtUtil jwtUtil; + + @Test + @DisplayName("모든 것이 리셋된 Cookie를 반환받을 수 있다.") + public void should_returnResetCookie() { + //given + + //when + Cookie cookie = jwtUtil.resetCookie(JwtRule.REFRESH_PREFIX); + + //then + assertThat(cookie.getMaxAge()).isEqualTo(0); + assertThat(cookie.getPath()).isEqualTo("/"); + assertThat(cookie.getValue()).isNull(); + } + +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/global/security/service/TokenServiceTest.java b/src/test/java/com/genius/gitget/global/security/service/TokenServiceTest.java new file mode 100644 index 00000000..22aa3a9e --- /dev/null +++ b/src/test/java/com/genius/gitget/global/security/service/TokenServiceTest.java @@ -0,0 +1,101 @@ +package com.genius.gitget.global.security.service; + +import static com.genius.gitget.global.util.exception.ErrorCode.JWT_NOT_FOUND_IN_DB; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.global.security.domain.Token; +import com.genius.gitget.global.security.repository.TokenRepository; +import com.genius.gitget.global.util.exception.BusinessException; +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.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class TokenServiceTest { + private String identifier = "identifier"; + private String refreshToken = "refresh token example"; + private Token token; + + @Autowired + private TokenService tokenService; + + @Autowired + private TokenRepository tokenRepository; + + + @BeforeEach + void setup() { + token = tokenRepository.save(new Token(identifier, refreshToken)); + } + + @AfterEach + void clearMongo() { + tokenRepository.deleteAll(); + } + + @Nested + @DisplayName("DB에 저장되어 있는 Token 객체를 찾으려 할 때") + class describe_find_stored_token { + @Nested + @DisplayName("사용자의 식별자(identifier)를 전달하면") + class context_pass_identifier { + @Test + @DisplayName("저장되어 있던 Token 객체를 반환받을 수 있다.") + public void it_returns_stored_Token() { + Token byIdentifier = tokenService.findByIdentifier(identifier); + assertThat(byIdentifier.getIdentifier()).isEqualTo(identifier); + assertThat(byIdentifier.getToken()).isEqualTo(refreshToken); + } + } + } + + @Nested + @DisplayName("Refresh token 탈취 여부를 확인할 때") + class describe_check_hijack { + @Nested + @DisplayName("사용자의 식별자와 요청받은 토큰을 전달하면") + class context_pass_identifier_and_token { + @Test + @DisplayName("저장되어 있던 토큰와 같으면 false를 반환한다.") + public void it_returns_false_token_same() { + boolean refreshHijacked = tokenService.isRefreshHijacked(identifier, refreshToken); + assertThat(refreshHijacked).isFalse(); + } + + @Test + @DisplayName("저장되어 있던 토큰과 다르다면 true를 반환한다.") + public void it_returns_true_token_different() { + String fakeRefreshToken = "fake refresh token"; + tokenRepository.save(new Token(identifier, fakeRefreshToken)); + + boolean refreshHijacked = tokenService.isRefreshHijacked(identifier, refreshToken); + assertThat(refreshHijacked).isTrue(); + } + } + } + + @Nested + @DisplayName("저장되어 있던 토큰을 삭제하고자 할 때") + class describe_delete_token { + @Nested + @DisplayName("사용자의 식별자를 전달하면") + class context_pass_user_identifier { + @Test + @DisplayName("저장되어 있는 토큰을 삭제한다.") + public void it_delete_stored_token() { + tokenService.deleteById(identifier); + + assertThatThrownBy(() -> tokenService.findByIdentifier(identifier)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(JWT_NOT_FOUND_IN_DB.getMessage()); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/page/CustomPageImplTest.java b/src/test/java/com/genius/gitget/page/CustomPageImplTest.java new file mode 100644 index 00000000..6440a913 --- /dev/null +++ b/src/test/java/com/genius/gitget/page/CustomPageImplTest.java @@ -0,0 +1,42 @@ +package com.genius.gitget.page; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.global.page.CustomPageImpl; + +public class CustomPageImplTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void testCustomPageImplSerialization() throws JsonProcessingException { + + // 데이터 목록과 페이지 정보를 설정 + List data = List.of("item1", "item2", "item3"); + PageRequest pageRequest = PageRequest.of(0, 10); + + // CustomPageImpl 객체 생성 + CustomPageImpl customPage = new CustomPageImpl<>(data, pageRequest, 3L); + + // CustomPageImpl 객체를 JSON으로 직렬화 + String json = objectMapper.writeValueAsString(customPage); + + System.out.println(json); + + // JSON 문자열을 다시 CustomPageImpl 객체로 역직렬화 + CustomPageImpl deserializedPage = objectMapper.readValue(json, CustomPageImpl.class); + + assertNotNull(deserializedPage); + assertEquals(customPage.getContent(), deserializedPage.getContent()); + assertEquals(customPage.getTotalElements(), deserializedPage.getTotalElements()); + assertEquals(customPage.getPageable().getPageNumber(), deserializedPage.getPageable().getPageNumber()); + } +} + diff --git a/src/test/java/com/genius/gitget/payment/controller/PaymentControllerTest.java b/src/test/java/com/genius/gitget/payment/controller/PaymentControllerTest.java new file mode 100644 index 00000000..493d4520 --- /dev/null +++ b/src/test/java/com/genius/gitget/payment/controller/PaymentControllerTest.java @@ -0,0 +1,124 @@ +package com.genius.gitget.payment.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + + +@SpringBootTest +@Transactional +public class PaymentControllerTest { + + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + + @Autowired + TopicRepository topicRepository; + @Autowired + FilesManager filesManager; + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("결제 내역 조회를 요청하면, 상태코드 200을 반환한다.") + public void 결제_내역_조회_성공() throws Exception { + + mockMvc.perform(get("/api/payment").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()); + } + + // 토스페이먼츠 PG사 결제 테스트 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("결제 요청을 성공하면, 상태코드 200을 반환한다.") + public void 결제_요청_성공() throws Exception { + Map input = new HashMap<>(); + input.put("amount", 1000L); + input.put("orderName", "park-kim"); + input.put("pointAmount", 100L); + input.put("userEmail", "kimdozzi"); + + mockMvc.perform(post("/api/payment/toss").headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("결제 요청을 실패하면, 상태코드 4xx을 반환한다.") + public void 결제_요청_실패_1() throws Exception { + Map input = new HashMap<>(); + input.put("amount", 1000L); + input.put("orderName", "park-kim"); + input.put("pointAmount", 100L); + input.put("userEmail", "test@gmail.com"); + + mockMvc.perform(post("/api/payment/toss").headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("결제 요청을 실패하면, 상태코드 4xx을 반환한다.") + public void 결제_요청_실패_2() throws Exception { + + mockMvc.perform(post("/api/payment/toss").headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("결제 요청을 실패하면, 상태코드 4xx을 반환한다.") + public void 결제_요청_실패_3() throws Exception { + Map input = new HashMap<>(); + input.put("amount", 1000L); + input.put("orderName", "park-kim"); + input.put("pointAmount", 100L); + input.put("userEmail", "test@gmail.com"); + + mockMvc.perform(post("/api/payment/toss").headers(tokenTestUtil.createAccessHeaders()) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } +} diff --git a/src/test/java/com/genius/gitget/payment/service/PaymentServiceTest.java b/src/test/java/com/genius/gitget/payment/service/PaymentServiceTest.java new file mode 100644 index 00000000..950437a1 --- /dev/null +++ b/src/test/java/com/genius/gitget/payment/service/PaymentServiceTest.java @@ -0,0 +1,186 @@ +package com.genius.gitget.payment.service; + +import static com.genius.gitget.store.item.domain.ItemCategory.CERTIFICATION_PASSER; +import static com.genius.gitget.store.item.domain.ItemCategory.POINT_MULTIPLIER; +import static org.assertj.core.api.Assertions.assertThat; + +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.likes.service.LikesService; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.file.repository.FilesRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.dto.ItemResponse; +import com.genius.gitget.store.item.facade.StoreFacade; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.repository.OrdersRepository; +import com.genius.gitget.store.payment.domain.Payment; +import com.genius.gitget.store.payment.dto.PaymentDetailsResponse; +import com.genius.gitget.store.payment.dto.PaymentRequest; +import com.genius.gitget.store.payment.dto.PaymentResponse; +import com.genius.gitget.store.payment.repository.PaymentRepository; +import com.genius.gitget.store.payment.service.PaymentService; +import com.genius.gitget.topic.repository.TopicRepository; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Slf4j +public class PaymentServiceTest { + + @Autowired + UserRepository userRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + TopicRepository topicRepository; + @Autowired + LikesRepository likesRepository; + @Autowired + LikesService likesService; + @Autowired + FilesRepository filesRepository; + @Autowired + private PaymentService paymentService; + @Autowired + private PaymentRepository paymentRepository; + @Autowired + private StoreFacade storeFacade; + @Autowired + private ItemRepository itemRepository; + @Autowired + private OrdersRepository ordersRepository; + + + private User getSavedUser() { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("neo5188@gmail.com") + .build() + ); + } + + @ParameterizedTest + @EnumSource(mode = Mode.EXCLUDE, names = {"PROFILE_FRAME"}) + public void 사용자는_아이템을_구매하고_결제내역을_조회할_수_있다(ItemCategory itemCategory) { + User user = getSavedUser(); + Item item = getSavedItem(itemCategory); + getSavedOrder(user, item, itemCategory, 0); + user.updatePoints(1000L); + + ItemResponse itemResponse = storeFacade.orderItem(user, item.getIdentifier()); + assertThat(itemResponse.getItemCategory()).isEqualTo(itemCategory); + + Page paymentDetails = paymentService.getPaymentDetails(user, PageRequest.of(0, 10)); + List content = paymentDetails.getContent(); + + assertThat(user.getPoint()).isEqualTo(900); + System.out.println(itemCategory); + if (itemCategory.equals(CERTIFICATION_PASSER)) { + assertThat(content.get(0).getOrderName()).isEqualTo("인증 패스권"); + } else if (itemCategory.equals(POINT_MULTIPLIER)) { + assertThat(content.get(0).getOrderName()).isEqualTo("챌린지 보상 획득 2배 아이템"); + } + assertThat(content.get(0).getOrderType()).isEqualTo("아이템 구매"); + assertThat(content.get(0).getDecreasedPoint()).isEqualTo("100"); + } + + private Item getSavedItem(ItemCategory itemCategory) { + return itemRepository.save(Item.builder() + .itemCategory(itemCategory) + .cost(100) + .name(itemCategory.getName()) + .identifier(8) + .build()); + } + + private Orders getSavedOrder(User user, Item item, ItemCategory itemCategory, int count) { + Orders orders = Orders.of(count, itemCategory); + orders.setItem(item); + orders.setUser(user); + return ordersRepository.save(orders); + } + + @Nested + class 사용자가_결제요청을_할_때 { + @Test + public void 존재하지_않는_사용자라면_실패한다() { + User user = userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("kimdozzi") + .build() + ); + Assertions.assertNotNull(user); + Assertions.assertThrows(BusinessException.class, + () -> paymentService.requestTossPayment(user, PaymentRequest.builder() + .amount(100L) + .orderName("포인트 충전") + .userEmail("neo5188@gmail.com") + .pointAmount(10L).build())); + + } + + @Test + public void 결제금액이_100원_미만이면_실패한다() { + User user = getSavedUser(); + Assertions.assertThrows(BusinessException.class, () -> + paymentService.requestTossPayment(user, PaymentRequest.builder() + .amount(99L) + .orderName("포인트 충전") + .userEmail("neo5188@gmail.com") + .pointAmount(9L).build())); + } + + @Test + public void 정해진_금액이_아닐경우_실패한다() { + User user = getSavedUser(); + Assertions.assertThrows(BusinessException.class, () -> + paymentService.requestTossPayment(user, PaymentRequest.builder() + .amount(100L) + .orderName("포인트 충전") + .userEmail("neo5188@gmail.com") + .pointAmount(10L).build())); + } + + @Test + public void 최소금액이_100원_이상이고_정해진_금액에_포함된다면_성공한다() { + User user = getSavedUser(); + PaymentResponse paymentResponse = paymentService.requestTossPayment(user, PaymentRequest.builder() + .amount(5000L) + .orderName("포인트 충전") + .userEmail("neo5188@gmail.com") + .pointAmount(500L).build()); + + List payments = paymentRepository.findPaymentDetailsByUserId(user.getId()); + + for (Payment payment : payments) { + assertThat(payment.getUser().getIdentifier()) + .isEqualTo(paymentResponse.getUserEmail()); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/profile/controller/ProfileControllerTest.java b/src/test/java/com/genius/gitget/profile/controller/ProfileControllerTest.java new file mode 100644 index 00000000..44bc56ec --- /dev/null +++ b/src/test/java/com/genius/gitget/profile/controller/ProfileControllerTest.java @@ -0,0 +1,307 @@ +package com.genius.gitget.profile.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.likes.service.LikesService; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + + +@SpringBootTest +@Transactional +public class ProfileControllerTest { + private static Topic savedTopic1, savedTopic2; + private static Instance savedInstance1, savedInstance2; + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + @Autowired + TopicRepository topicRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + FilesManager filesManager; + @Autowired + LikesService likesService; + @Autowired + UserRepository userRepository; + @Autowired + LikesRepository likesRepository; + @Autowired + ParticipantRepository participantRepository; + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + savedTopic1 = getSavedTopic(); + savedTopic2 = getSavedTopic(); + + savedInstance1 = getSavedInstance("title1", "FE", 50, 1000); + savedInstance2 = getSavedInstance("title2", "BE, CS", 50, 1000); + + savedInstance1.setTopic(savedTopic1); + savedInstance2.setTopic(savedTopic1); + } + + // 사용자 상세 정보 조회 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 상세 정보 조회에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_상세_정보_조회_성공() throws Exception { + + mockMvc.perform(get("/api/profile").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 상세 정보 조회 시 같은 사용자 정보가 있으면 실패하고, 4xx(IncorrectResultSizeDataAccessException)를 반환한다.") + public void 사용자_상세_정보_조회_실패() throws Exception { + User user = getSavedUser(); + mockMvc.perform(get("/api/profile").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + // 사용자 정보 조회 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 정보 조회에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_정보_조회_성공() throws Exception { + List users = userRepository.findAllByIdentifier("kimdozzi"); + Long id = null; + for (User user : users) { + if (user.getIdentifier().equals("kimdozzi")) { + id = user.getId(); + } + } + Map input = new HashMap<>(); + input.put("userId", id); + + mockMvc.perform(post("/api/profile") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 정보 조회에 실패하면, 상태 코드 4xx을 반환한다.") + public void 사용자_정보_조회_실패() throws Exception { + List users = userRepository.findAllByIdentifier("kimdozzi"); + Long id = null; + for (User user : users) { + if (user.getIdentifier().equals("kimdozzi")) { + id = user.getId(); + } + } + Map input = new HashMap<>(); + input.put("userId", id + 1); + + mockMvc.perform(post("/api/profile") + .headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + // 관심사 조회 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 관심사 조회에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_관심사_조회_성공() throws Exception { + mockMvc.perform(get("/api/profile/interest").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()); + } + + // 관심사 수정 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 관심사 수정에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_관심사_수정_성공() throws Exception { + + Map> input = new HashMap<>(); + input.put("tags", new ArrayList<>(Arrays.asList("FE", "BE", "ML"))); + + mockMvc.perform(post("/api/profile/interest").headers(tokenTestUtil.createAccessHeaders()) + .content(objectMapper.writeValueAsString(input)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + + User findUser = null; + List users = userRepository.findAllByIdentifier("kimdozzi"); + for (User user : users) { + if (user.getIdentifier().equals("kimdozzi")) { + findUser = user; + } + } + + Assertions.assertThat(findUser.getTags()).isEqualTo(String.join(",", findUser.getTags())); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 관심사 수정에 실패하면, 상태 코드 4xx을 반환한다.") + public void 사용자_관심사_수정_실패_1() throws Exception { + + Map> input = new HashMap<>(); + input.put("tags", new ArrayList<>(Arrays.asList("FE", "BE", "ML"))); + + mockMvc.perform(post("/api/profile/interest").headers(tokenTestUtil.createAccessHeaders()) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 관심사 수정에 실패하면, 상태 코드 4xx을 반환한다.") + public void 사용자_관심사_수정_실패_2() throws Exception { + + mockMvc.perform(post("/api/profile/interest").headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + + // 챌린지 현황 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 챌린지 현황 조회에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_챌린지_현황_성공() throws Exception { + mockMvc.perform(get("/api/profile/challenges").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()); + } + + // 탈퇴하기 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 탈퇴에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_탈퇴_성공() throws Exception { + + Map input = new HashMap<>(); + input.put("reason", "이용이 불편해서"); + + mockMvc.perform(delete("/api/profile").headers(tokenTestUtil.createAccessHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 탈퇴 사유없이 탈퇴를 요청하면 실패하고, 상태 코드 4xx을 반환한다.") + public void 사용자_탈퇴_실패() throws Exception { + mockMvc.perform(delete("/api/profile").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + // 포인트 조회 + @Test + @WithMockCustomUser(identifier = "kimdozzi") + @DisplayName("사용자 포인트 조회에 성공하면, 상태 코드 200을 반환한다.") + public void 사용자_포인트_조회_성공() throws Exception { + mockMvc.perform(get("/api/profile/point").headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()); + } + + + private User getSavedUser() { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname1") + .tags("FE, BE") + .providerInfo(ProviderInfo.GITHUB) + .information("info") + .identifier("kimdozzi") + .build() + ); + } + + private Topic getSavedTopic() { + Topic topic = topicRepository.save( + Topic.builder() + .title("title") + .notice("notice") + .description("description") + .tags("BE") + .pointPerPerson(100) + .build() + ); + + return topic; + } + + private Instance getSavedInstance(String title, String tags, int participantCnt, int pointPerPerson) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(pointPerPerson) + .certificationMethod("인증 방법") + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } +} diff --git a/src/test/java/com/genius/gitget/profile/service/ProfileServiceTest.java b/src/test/java/com/genius/gitget/profile/service/ProfileServiceTest.java new file mode 100644 index 00000000..9bee10bb --- /dev/null +++ b/src/test/java/com/genius/gitget/profile/service/ProfileServiceTest.java @@ -0,0 +1,275 @@ +package com.genius.gitget.profile.service; + +import static com.genius.gitget.global.security.constants.ProviderInfo.GITHUB; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.likes.domain.Likes; +import com.genius.gitget.challenge.likes.repository.LikesRepository; +import com.genius.gitget.challenge.likes.service.LikesService; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.profile.dto.UserChallengeResultResponse; +import com.genius.gitget.profile.dto.UserDetailsInformationResponse; +import com.genius.gitget.profile.dto.UserInformationResponse; +import com.genius.gitget.profile.dto.UserInformationUpdateRequest; +import com.genius.gitget.profile.dto.UserInterestResponse; +import com.genius.gitget.profile.dto.UserInterestUpdateRequest; +import com.genius.gitget.profile.dto.UserPointResponse; +import com.genius.gitget.signout.Signout; +import com.genius.gitget.signout.SignoutRepository; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.assertj.core.api.Assertions; +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.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@Rollback +public class ProfileServiceTest { + + static User user1, user2; + static Topic topic1; + static Instance instance1, instance2, instance3; + @Autowired + UserRepository userRepository; + @Autowired + InstanceRepository instanceRepository; + @Autowired + TopicRepository topicRepository; + @Autowired + LikesRepository likesRepository; + @Autowired + LikesService likesService; + @Autowired + ProfileFacade profileFacade; + @Autowired + SignoutRepository signoutRepository; + + @BeforeEach + void setup() { + user1 = getSavedUser("neo5188@gmail.com", GITHUB, "alias1"); + user2 = getSavedUser("neo7269@naver.com", GITHUB, "alias2"); + + topic1 = getSavedTopic("1일 1커밋", "BE"); + + instance1 = getSavedInstance("1일 1커밋", "BE", 50); + instance2 = getSavedInstance("1일 1커밋", "BE", 100); + instance3 = getSavedInstance("1일 1알고리즘", "CS,BE,FE", 500); + + //== 연관관계 ==// + instance1.setTopic(topic1); + instance2.setTopic(topic1); + instance3.setTopic(topic1); + + Likes likes1 = new Likes(user1, instance1); + Likes likes2 = new Likes(user1, instance2); + Likes likes3 = new Likes(user1, instance3); + likesRepository.save(likes1); + likesRepository.save(likes2); + likesRepository.save(likes3); + } + + private User getSavedUser(String identifier, ProviderInfo providerInfo, String nickname) { + User user = userRepository.save( + User.builder() + .identifier(identifier) + .providerInfo(providerInfo) + .role(Role.ADMIN) + .tags("BE,FE") + .nickname(nickname) + .build() + ); + return user; + } + + private Topic getSavedTopic(String title, String tags) { + Topic topic = topicRepository.save( + Topic.builder() + .title(title) + .tags(tags) + .description("토픽 설명") + .pointPerPerson(100) + .build() + ); + return topic; + } + + private Instance getSavedInstance(String title, String tags, int participantCnt) { + LocalDateTime now = LocalDateTime.now(); + Instance instance = instanceRepository.save( + Instance.builder() + .tags(tags) + .title(title) + .description("description") + .progress(Progress.PREACTIVITY) + .pointPerPerson(100) + .certificationMethod("인증 방법") + .startedDate(now) + .completedDate(now.plusDays(1)) + .build() + ); + instance.updateParticipantCount(participantCnt); + return instance; + } + + @Nested + @DisplayName("유저 상세 정보 조회") + class Describe_getUserDetailsInformation { + + @Test + @DisplayName("유저의 상세 정보를 반환한다.") + void it_returns_user_details_information() { + UserDetailsInformationResponse userDetailsInformation = profileFacade.getUserDetailsInformation(user1); + Assertions.assertThat(userDetailsInformation.getIdentifier()).isEqualTo("neo5188@gmail.com"); + } + } + + @Nested + @DisplayName("유저 정보 조회") + class Describe_getUserInformation { + + @Test + @DisplayName("유저의 정보를 반환한다.") + void it_returns_user_information() { + List userIdList = new ArrayList<>(); + List all = userRepository.findAll(); + for (User user : all) { + if (Objects.equals(user.getNickname(), "Guest")) { + continue; + } + Long id = user.getId(); + userIdList.add(id); + } + + for (int i = userIdList.size() - 1; i >= 0; i--) { + UserInformationResponse userInformation = profileFacade.getUserInformation(userIdList.get(i)); + Assertions.assertThat(userInformation.getNickname()).isEqualTo("alias" + (i + 1)); + } + } + } + + @Nested + @DisplayName("유저 정보 수정") + class Describe_updateUserInformation { + + @Test + @DisplayName("유저의 정보를 수정한다.") + void it_updates_user_information() { + profileFacade.updateUserInformation(user1, + UserInformationUpdateRequest.builder() + .nickname("수정된 nickname") + .information("수정된 information") + .build()); + + User user = userRepository.findByIdentifier(user1.getIdentifier()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + Assertions.assertThat(user.getNickname()).isEqualTo("수정된 nickname"); + } + } + + @Nested + @DisplayName("유저 관심사 조회") + class Describe_getUserInterest { + + @Test + @DisplayName("유저의 관심사를 반환한다.") + void it_returns_user_interest() { + UserInterestResponse userInterest = profileFacade.getUserInterest(user1); + List tags = userInterest.getTags(); + String join = String.join(",", tags); + Assertions.assertThat(join).isEqualTo("BE,FE"); + } + } + + @Nested + @DisplayName("유저 관심사 수정") + class Describe_updateUserTags { + + @Test + @DisplayName("유저의 관심사를 수정한다.") + void it_updates_user_tags() { + profileFacade.updateUserTags(user1, + UserInterestUpdateRequest.builder().tags(new ArrayList<>(Arrays.asList("FE", "BE"))).build()); + User user = userRepository.findByIdentifier(user1.getIdentifier()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + Assertions.assertThat(user.getTags()).isEqualTo("FE,BE"); + } + } + + @Nested + @DisplayName("회원 탈퇴") + class Describe_deleteUserInformation { + + @Test + @DisplayName("유저의 정보를 삭제한다.") + void it_deletes_user_information() { + User user = userRepository.findByIdentifier(user1.getIdentifier()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + String userIdentifier = user.getIdentifier(); + profileFacade.deleteUserInformation(user, "서비스 이용 불편"); + + assertThrows(BusinessException.class, + () -> userRepository.findByIdentifier(userIdentifier) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND))); + + Signout byIdentifier = signoutRepository.findByIdentifier(userIdentifier); + + Assertions.assertThat(byIdentifier.getReason()).isEqualTo("서비스 이용 불편"); + } + } + + @Nested + @DisplayName("유저 포인트 조회") + class Describe_getUserPoint { + + @Test + @DisplayName("유저의 포인트를 반환한다.") + void it_returns_user_point() { + User user = userRepository.findByIdentifier(user1.getIdentifier()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + user.updatePoints(1500L); + userRepository.save(user); + UserPointResponse userPoint = profileFacade.getUserPoint(user1); + Assertions.assertThat(userPoint.getPoint()).isEqualTo(1500); + } + } + + @Nested + @DisplayName("챌린지 현황 조회") + class Describe_getUserChallengeResult { + + @Test + @DisplayName("유저의 챌린지 현황을 반환한다.") + void it_returns_user_challenge_result() { + // TODO 챌린지 현황 조회 + User user = userRepository.findByIdentifier(user1.getIdentifier()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + UserChallengeResultResponse userChallengeResult = profileFacade.getUserChallengeResult(user); + System.out.println(userChallengeResult.getBeforeStart()); + System.out.println(userChallengeResult.getProcessing()); + System.out.println(userChallengeResult.getFail()); + System.out.println(userChallengeResult.getSuccess()); + } + } +} diff --git a/src/test/java/com/genius/gitget/store/facade/StoreFacadeTest.java b/src/test/java/com/genius/gitget/store/facade/StoreFacadeTest.java new file mode 100644 index 00000000..d19c83fb --- /dev/null +++ b/src/test/java/com/genius/gitget/store/facade/StoreFacadeTest.java @@ -0,0 +1,469 @@ +package com.genius.gitget.store.facade; + +import static com.genius.gitget.challenge.participant.domain.JoinResult.SUCCESS; +import static com.genius.gitget.challenge.participant.domain.RewardStatus.NO; +import static com.genius.gitget.challenge.participant.domain.RewardStatus.YES; +import static com.genius.gitget.global.util.exception.ErrorCode.ALREADY_REWARDED; +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_GET_REWARDS; +import static com.genius.gitget.global.util.exception.ErrorCode.CAN_NOT_USE_PASS_ITEM; +import static com.genius.gitget.store.item.domain.ItemCategory.CERTIFICATION_PASSER; +import static com.genius.gitget.store.item.domain.ItemCategory.POINT_MULTIPLIER; +import static com.genius.gitget.store.item.domain.ItemCategory.PROFILE_FRAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.repository.CertificationRepository; +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.repository.InstanceRepository; +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.repository.ParticipantRepository; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.EquipStatus; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.dto.ItemResponse; +import com.genius.gitget.store.item.dto.ProfileResponse; +import com.genius.gitget.store.item.facade.StoreFacade; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.repository.OrdersRepository; +import com.genius.gitget.util.certification.CertificationFactory; +import com.genius.gitget.util.instance.InstanceFactory; +import com.genius.gitget.util.participant.ParticipantFactory; +import com.genius.gitget.util.store.StoreFactory; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +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.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class StoreFacadeTest { + private User user; + private LocalDate currentDate = LocalDate.now(); + + @Autowired + private StoreFacade storeFacade; + @Autowired + private UserRepository userRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private OrdersRepository ordersRepository; + @Autowired + private InstanceRepository instanceRepository; + @Autowired + private ParticipantRepository participantRepository; + @Autowired + private CertificationRepository certificationRepository; + + + @BeforeEach + void setup() { + user = userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("identifier") + .build() + ); + + } + + @Nested + @DisplayName("아이템 목록 조회 시") + class describe_get_item_list { + @Nested + @DisplayName("카테고리 별로 조회를 하면") + class context_inquiry_by_category { + @ParameterizedTest + @DisplayName("카테고리에 해당하는 아이템들을 받아올 수 있다.") + @EnumSource(ItemCategory.class) + public void it_returns_item_list(ItemCategory itemCategory) { + Item item = itemRepository.save(StoreFactory.createItem(itemCategory)); + ordersRepository.save(StoreFactory.createOrders(user, item, itemCategory, 1)); + + List itemResponses = storeFacade.getItemsByCategory(user, itemCategory); + + for (ItemResponse itemResponse : itemResponses) { + assertThat(itemResponse.getName()).contains(itemCategory.getName()); + assertThat(itemResponse.getDetails()).isNotBlank(); + } + } + } + } + + @Nested + @DisplayName("아이템 구매 시") + class describe_purchase_item { + @Nested + @DisplayName("사용자의 포인트가 충분하다면") + class context_user_have_enough_point { + @ParameterizedTest + @DisplayName("아이템을 구매할 수 있다.") + @EnumSource(ItemCategory.class) + public void it_returns_200(ItemCategory itemCategory) { + Item item = itemRepository.save(StoreFactory.createItem(itemCategory)); + user.updatePoints(1000L); + + ItemResponse itemResponse = storeFacade.orderItem(user, item.getIdentifier()); + + assertThat(itemResponse.getItemId()).isEqualTo(item.getIdentifier()); + assertThat(itemResponse.getName()).isEqualTo(item.getName()); + assertThat(itemResponse.getCost()).isEqualTo(item.getCost()); + assertThat(itemResponse.getCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("사용자의 포인트가 충분하지 않다면") + class context_user_have_not_enough_point { + @ParameterizedTest + @DisplayName("NOT_ENOUGH_POINT 예외가 발생한다.") + @EnumSource(ItemCategory.class) + public void it_throws_NOT_ENOUGH_POINT_exception(ItemCategory itemCategory) { + Item item = itemRepository.save(StoreFactory.createItem(itemCategory)); + + assertThatThrownBy(() -> storeFacade.orderItem(user, item.getIdentifier())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.NOT_ENOUGH_POINT.getMessage()); + } + } + } + + @Nested + @DisplayName("아이템 사용 시") + class describe_use_item { + Item item; + Instance instance; + + @BeforeEach + void setup() { + instance = instanceRepository.save(InstanceFactory.createPreActivity(10)); + } + + @Nested + @DisplayName("카테고리에 상관없이 Orders의 정보를 DB에서 조회했을 때") + class context_inquiry_orders { + @ParameterizedTest + @DisplayName("정보는 존재하지만 count가 0개 이하일 때, HAS_NO_ITEM 예외를 발생한다") + @EnumSource(ItemCategory.class) + public void it_throws_HAS_NO_ITEM_exception(ItemCategory itemCategory) { + item = itemRepository.save(StoreFactory.createItem(itemCategory)); + ordersRepository.save(StoreFactory.createOrders(user, item, itemCategory, 0)); + + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.HAS_NO_ITEM.getMessage()); + } + + @ParameterizedTest + @DisplayName("정보가 존재하지 않을 때 ORDERS_NOT_FOUND 예외가 발생한다.") + @EnumSource(ItemCategory.class) + public void it_throws_ORDERS_NOT_FOUND_exception(ItemCategory itemCategory) { + item = itemRepository.save(StoreFactory.createItem(itemCategory)); + + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.ORDERS_NOT_FOUND.getMessage()); + } + } + } + + @Nested + @DisplayName("인증 패스 아이템 사용 시") + class describe_use_certification_pass_item { + Instance instance; + Participant participant; + Item item; + Orders orders; + + @BeforeEach + void setup() { + item = itemRepository.save(StoreFactory.createItem(CERTIFICATION_PASSER)); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, CERTIFICATION_PASSER, 5)); + } + + @Nested + @DisplayName("아이템을 가지고 있고, 인증 상태를 조회했을 때") + class context_has_item_and_check_certification_status { + @BeforeEach + void setup() { + instance = instanceRepository.save(InstanceFactory.createActivity(10)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + } + + @Test + @DisplayName("인증 정보가 존재하지 않는 경우 아이템을 사용할 수 있다.") + public void it_returns_200_when_certification_not_exist() { + int holding = orders.getCount(); + storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate); + assertThat(orders.getCount()).isEqualTo(holding - 1); + } + + @Test + @DisplayName("인증 정보는 있으나 NOT_YET인 경우 아이템을 사용할 수 있다.") + public void it_returns_200_when_certification_is_NOT_YET() { + int holding = orders.getCount(); + certificationRepository.save( + CertificationFactory.createNotYet(participant, currentDate) + ); + storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate); + + assertThat(orders.getCount()).isEqualTo(holding - 1); + } + + @ParameterizedTest + @DisplayName("인증 정보는 있으나 CERTIFICATED 혹은 PASSED 라면 예외가 발생한다.") + @EnumSource(mode = Mode.INCLUDE, names = {"CERTIFICATED", "PASSED"}) + public void it_throws_exception_status_is_CERTIFICATED_or_PASSED(CertificateStatus certificateStatus) { + certificationRepository.save( + CertificationFactory.create(certificateStatus, currentDate, participant) + ); + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_USE_PASS_ITEM.getMessage()); + } + } + + @Nested + @DisplayName("아이템을 가지고 있고, 인스턴스 상태를 조회했을 때") + class context_has_item_and_check_instance_status { + @Test + @DisplayName("인스턴스의 상태가 ACTIVITY가 아니라면 NOT_ACTIVITY_INSTANCE 예외가 발생한다.") + public void it_throws_exception_when_instance_not_ACTIVITY() { + instance = instanceRepository.save(InstanceFactory.createPreActivity(10)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.NOT_ACTIVITY_INSTANCE.getMessage()); + } + + @Test + @DisplayName("인스턴스의 상태가 ACTIVITY라면 아이템을 사용할 수 있다.") + public void it_returns_200_instance_status_is_ACTIVITY() { + int holding = orders.getCount(); + instance = instanceRepository.save(InstanceFactory.createActivity(10)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate); + + assertThat(orders.getCount()).isEqualTo(holding - 1); + } + } + } + + @Nested + @DisplayName("포인트 2배 획득 아이템 사용 시") + class describe_use_point_multiplier_item { + Instance instance; + Participant participant; + Item item; + Orders orders; + + @BeforeEach + void setup() { + item = itemRepository.save(StoreFactory.createItem(POINT_MULTIPLIER)); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, POINT_MULTIPLIER, 5)); + } + + @Nested + @DisplayName("아이템을 가지고 있고, 인스턴스의 상태를 조회했을 때") + class context_has_item_and_check_instance_status { + @Test + @DisplayName("인스턴스의 상태가 DONE이 아니라면 예외가 발생한다.") + public void it_throws_exception_when_instance_not_DONE() { + instance = instanceRepository.save(InstanceFactory.createActivity(10)); + participant = participantRepository.save(ParticipantFactory.createProcessing(user, instance)); + + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_GET_REWARDS.getMessage()); + } + + @Test + @DisplayName("인스턴스의 상태가 DONE이라면 아이템을 사용할 수 있다.") + public void it_returns_200_when_instance_is_DONE() { + int holding = orders.getCount(); + instance = instanceRepository.save(InstanceFactory.createDone(10)); + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance, SUCCESS, NO)); + + storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate); + + assertThat(orders.getCount()).isEqualTo(holding - 1); + } + } + + @Nested + @DisplayName("사용 가능 여부를 확인했을 때") + class context_check_valid_to_use_item { + @BeforeEach + void setup() { + instance = instanceRepository.save(InstanceFactory.createDone(10)); + } + + @ParameterizedTest + @DisplayName("participant의 JoinResult가 SUCCESS가 아니라면 CAN_NOT_GET_REWARDS 예외가 발생한다.") + @EnumSource(mode = Mode.INCLUDE, names = {"PROCESSING", "FAIL"}) + public void it_throws_exception_when_JoinResult_not_SUCCESS(JoinResult joinResult) { + participant = participantRepository.save( + ParticipantFactory.createByJoinResult(user, instance, joinResult)); + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CAN_NOT_GET_REWARDS.getMessage()); + } + + @Test + @DisplayName("participant의 RewardStatus가 YES라면 ALREADY_REWARDED 예외가 발생한다.") + public void it_throws_exception_when_RewardStatus_is_YES() { + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance, SUCCESS, YES) + ); + assertThatThrownBy(() -> storeFacade.useItem(user, item.getIdentifier(), instance.getId(), currentDate)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ALREADY_REWARDED.getMessage()); + } + } + + @Nested + @DisplayName("아이템을 사용해서 아이템의 개수가 0이 되었을 때") + class context_item_count_is_zero { + @BeforeEach + void setup() { + instance = instanceRepository.save(InstanceFactory.createDone(10)); + participant = participantRepository.save( + ParticipantFactory.createByRewardStatus(user, instance, SUCCESS, NO)); + } + + @Test + @DisplayName("Orders 정보가 DB에서 삭제된다.") + public void it_delete_Orders_from_DB() { + int holding = 1; + orders = ordersRepository.save(StoreFactory.createOrders(user, item, POINT_MULTIPLIER, holding)); + storeFacade.useMultiplierItem(orders, instance.getId(), currentDate); + + Optional optionalOrders = ordersRepository.findById(orders.getId()); + assertThat(optionalOrders).isNotPresent(); + } + } + } + + @Nested + @DisplayName("프로필 아이템 사용 시") + class describe_use_profile_item { + Item item; + Orders orders; + + @BeforeEach + void setup() { + item = itemRepository.save(StoreFactory.createItem(PROFILE_FRAME)); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, PROFILE_FRAME, 2)); + } + + @Nested + @DisplayName("사용 가능 여부를 확인했을 때") + class context_check_valid_to_use_item { + @Test + @DisplayName("기존에 사용 중인 프로필 아이템이 있는 경우 TOO_MANY_USING_FRAME 예외가 발생한다.") + public void it_throws_exception_already_using_frame_exist() { + orders.updateEquipStatus(EquipStatus.IN_USE); + assertThatThrownBy(() -> storeFacade.useFrameItem(user.getId(), orders)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.TOO_MANY_USING_FRAME.getMessage()); + } + + @Test + @DisplayName("EquipStatus가 UNAVAILABLE인 경우 INVALID_EQUIP_CONDITION 예외가 발생한다.") + public void it_throws_exception_when_equipStatus_is_unavailable() { + orders.updateEquipStatus(EquipStatus.UNAVAILABLE); + assertThatThrownBy(() -> storeFacade.useFrameItem(user.getId(), orders)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.INVALID_EQUIP_CONDITION.getMessage()); + } + + @Test + @DisplayName("EquipStatus가 IN_USE인 경우 INVALID_EQUIP_CONDITION 예외가 발생한다.") + public void it_throws_exception_when_equipStatus_is_in_use() { + orders.updateEquipStatus(EquipStatus.IN_USE); + assertThatThrownBy(() -> storeFacade.useFrameItem(user.getId(), orders)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.TOO_MANY_USING_FRAME.getMessage()); + } + + @Test + @DisplayName("조건에 부합한다면 프로필 아이템을 사용할 수 있다.") + public void it_returns_200_when_condition_match() { + storeFacade.useFrameItem(user.getId(), orders); + assertThat(orders.getEquipStatus()).isEqualTo(EquipStatus.IN_USE); + } + } + } + + @Nested + @DisplayName("아이템 장착 해제 요청 시") + class describe_unmount_item { + Item item; + Orders orders; + + @Nested + @DisplayName("프로필 아이템이 아니라면") + class context_not_profile_item { + @ParameterizedTest + @DisplayName("응답 데이터가 포함되지 않는다.") + @EnumSource(mode = Mode.INCLUDE, names = {"POINT_MULTIPLIER", "CERTIFICATION_PASSER"}) + public void it_not_contain_response_data(ItemCategory itemCategory) { + item = itemRepository.save(StoreFactory.createItem(itemCategory)); + ordersRepository.save(StoreFactory.createOrders(user, item, itemCategory, 2)); + + List profileResponses = storeFacade.unmountFrame(user); + assertThat(profileResponses.size()).isEqualTo(0); + } + } + + @Nested + @DisplayName("프로필 아이템인 경우") + class context_profile_item { + @Test + @DisplayName("EquipStatus가 IN_USE라면 응답 데이터가 포함된다.") + public void it_contains_response_data_equipStatus_is_IN_USE() { + item = itemRepository.save(StoreFactory.createItem(PROFILE_FRAME)); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, PROFILE_FRAME, 2)); + orders.updateEquipStatus(EquipStatus.IN_USE); + + List profileResponses = storeFacade.unmountFrame(user); + assertThat(profileResponses.size()).isEqualTo(1); + } + + @ParameterizedTest + @DisplayName("EquipStatus가 IN_USE가 아니라면 응답 데이터가 포함되지 않는다.") + @EnumSource(mode = Mode.INCLUDE, names = {"UNAVAILABLE", "AVAILABLE"}) + public void it_not_contains_response_data_equipStatus_not_IN_USE(EquipStatus equipStatus) { + item = itemRepository.save(StoreFactory.createItem(PROFILE_FRAME)); + orders = ordersRepository.save(StoreFactory.createOrders(user, item, PROFILE_FRAME, 2)); + orders.updateEquipStatus(equipStatus); + + List profileResponses = storeFacade.unmountFrame(user); + assertThat(profileResponses.size()).isEqualTo(0); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/store/service/ItemServiceTest.java b/src/test/java/com/genius/gitget/store/service/ItemServiceTest.java new file mode 100644 index 00000000..ad98031c --- /dev/null +++ b/src/test/java/com/genius/gitget/store/service/ItemServiceTest.java @@ -0,0 +1,96 @@ +package com.genius.gitget.store.service; + +import static com.genius.gitget.store.item.domain.ItemCategory.CERTIFICATION_PASSER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.service.ItemService; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class ItemServiceTest { + @Autowired + ItemRepository itemRepository; + @Autowired + ItemService itemService; + + @ParameterizedTest + @DisplayName("DB에 저장되어 있는 아이템을 카테고리 별로 받아올 수 있다.") + @EnumSource(mode = Mode.INCLUDE, names = {"POINT_MULTIPLIER", "CERTIFICATION_PASSER"}) + public void should_findItems_when_passCategory(ItemCategory itemCategory) { + //given + Item item = getSavedItem(10, itemCategory); + + //when + List items = itemService.findAllByCategory(itemCategory); + + //then + assertThat(items.size()).isEqualTo(2); + Item foundItem = items.get(0); + assertThat(foundItem.getItemCategory()).isEqualTo(item.getItemCategory()); + } + + @Test + @DisplayName("DB에 저장되어 있는 아이템을 식별자 PK를 통해 조회할 수 있다.") + public void should_findItem_when_passPK() { + //given + Item item = getSavedItem(10, CERTIFICATION_PASSER); + + //when + Item foundItem = itemService.findById(item.getId()); + + //then + assertThat(item.getId()).isEqualTo(foundItem.getId()); + assertThat(item.getItemCategory()).isEqualTo(foundItem.getItemCategory()); + assertThat(item.getCost()).isEqualTo(foundItem.getCost()); + } + + @Test + @DisplayName("PK를 통해 아이템을 조회하려고 했을 때, 존재하지 않으면 예외를 발생시켜야 한다.") + public void should_throwException_when_pkNotExist() { + assertThatThrownBy(() -> itemService.findById(0L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.ITEM_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("식별 전용 값인 identifier를 통해 아이템을 조회할 수 있다.") + public void should_findItem_by_identifier() { + //given + int identifier = 10; + Item item = getSavedItem(identifier, CERTIFICATION_PASSER); + + //when + Item byIdentifier = itemService.findByIdentifier(identifier); + + //then + assertThat(item.getId()).isEqualTo(byIdentifier.getId()); + assertThat(byIdentifier.getItemCategory()).isEqualTo(CERTIFICATION_PASSER); + } + + private Item getSavedItem(int identifier, ItemCategory itemCategory) { + return itemRepository.save( + Item.builder() + .identifier(identifier) + .cost(100) + .itemCategory(itemCategory) + .build() + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/store/service/OrdersServiceTest.java b/src/test/java/com/genius/gitget/store/service/OrdersServiceTest.java new file mode 100644 index 00000000..28d8abbb --- /dev/null +++ b/src/test/java/com/genius/gitget/store/service/OrdersServiceTest.java @@ -0,0 +1,203 @@ +package com.genius.gitget.store.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.constants.ProviderInfo; +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.global.util.exception.ErrorCode; +import com.genius.gitget.store.item.domain.EquipStatus; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; +import com.genius.gitget.store.item.repository.ItemRepository; +import com.genius.gitget.store.item.repository.OrdersRepository; +import com.genius.gitget.store.item.service.OrdersService; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@SpringBootTest +@Transactional +class OrdersServiceTest { + @Autowired + private UserRepository userRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private OrdersRepository ordersRepository; + @Autowired + private OrdersService ordersService; + + @Test + @DisplayName("사용자가 특정 아이템을 보유하고 있을 때, 보유하고 있는 아이템의 개수를 반환받을 수 있다.") + public void should_returnItemCount_when_haveItem() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + getSavedOrder(user, item, 1); + + //when + int numOfItem = ordersService.countNumOfItem(user, item.getId()); + + //then + assertThat(numOfItem).isEqualTo(1); + } + + @Test + @DisplayName("사용자의 아이템 보유 정보가 DB에 저장되어있지 않을 때, 보유하고 있는 아이템의 개수를 요청하면 0을 반환한다.") + public void should_returnZero_when_dataNotSaved() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + + //when + int numOfItem = ordersService.countNumOfItem(user, item.getId()); + + //then + assertThat(numOfItem).isEqualTo(0); + } + + @Test + @DisplayName("아이템을 구매한 경우 User PK와 Item PK를 통해 주문 내역을 받아올 수 있다.") + public void should_getOrder_when_ordered() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + Orders orders = getSavedOrder(user, item, 1); + + //when + Optional optionalOrders = ordersService.findOptionalByOrderInfo(user.getId(), item.getId()); + + //then + assertThat(optionalOrders).isPresent(); + assertThat(optionalOrders.get()).isEqualTo(orders); + } + + @Test + @DisplayName("아이템을 구매하지 않은 경우 주문 내역을 받아왔을 때 Optional.null을 반환한다.") + public void should_returnOptional_when_notOrdered() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + + //when + Optional optionalOrders = ordersService.findOptionalByOrderInfo(user.getId(), item.getId()); + + //then + assertThat(optionalOrders).isNotPresent(); + } + + @Test + @DisplayName("구매를 한 경우, 구매 아이템의 장착 상황을 얻을 수 있다.") + public void should_getEquipStatus_when_ordered() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + getSavedOrder(user, item, 1); + + //when + EquipStatus equipStatus = ordersService.getEquipStatus(user.getId(), item.getId()); + + //then + assertThat(equipStatus).isEqualTo(EquipStatus.AVAILABLE); + } + + @Test + @DisplayName("아이템 구매를 하지 않은 경우, 장착 상황을 반환받을 때 UNAVAILABLE을 반환한다.") + public void should_returnUnavailable_when_notOrdered() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + + //when + EquipStatus equipStatus = ordersService.getEquipStatus(user.getId(), item.getId()); + + //then + assertThat(equipStatus).isEqualTo(EquipStatus.UNAVAILABLE); + } + + @Test + @DisplayName("사용자가 장착하고 있는 프로필 프레임이 하나 있을 때, 해당 프레임 아이템을 반환한다.") + public void should_returnPK_when_equipOneFrame() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + Orders orders = getSavedOrder(user, item, 1); + + //when + Item usingFrame = ordersService.getUsingFrameItem(user.getId()); + + //then + assertThat(item.getItemCategory()).isEqualTo(usingFrame.getItemCategory()); + } + + @Test + @DisplayName("오류로 인해 사용자가 장착하고 있는 프로필 프레임이 두 개 이상일 때, 예외를 발생한다.") + public void should_throwException_when_numOfFrameMoreThanTwo() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + Orders orders1 = getSavedOrder(user, item, 1); + Orders orders2 = getSavedOrder(user, item, 1); + + orders1.updateEquipStatus(EquipStatus.IN_USE); + orders2.updateEquipStatus(EquipStatus.IN_USE); + + //when & then + assertThatThrownBy(() -> ordersService.getUsingFrameItem(user.getId())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.TOO_MANY_USING_FRAME.getMessage()); + } + + @Test + @DisplayName("사용자가 장착하고 있는 프레임이 없을 때, 더미 데이터를 전달받는다.") + public void should_returnDummy_when_notEquipped() { + //given + User user = getSavedUser(); + Item item = getSavedItem(ItemCategory.PROFILE_FRAME); + + //when + Item usingFrame = ordersService.getUsingFrameItem(user.getId()); + + //then + assertThat(usingFrame.getId()).isNull(); + } + + + private User getSavedUser() { + return userRepository.save( + User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("githubId") + .information("information") + .tags("BE,FE") + .build() + ); + } + + private Item getSavedItem(ItemCategory itemCategory) { + return itemRepository.save( + Item.builder() + .itemCategory(itemCategory) + .build() + ); + } + + private Orders getSavedOrder(User user, Item item, int count) { + Orders orders = Orders.of(count, item.getItemCategory()); + orders.setUser(user); + orders.setItem(item); + return ordersRepository.save(orders); + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/topic/controller/TopicControllerTest.java b/src/test/java/com/genius/gitget/topic/controller/TopicControllerTest.java new file mode 100644 index 00000000..d190e3a2 --- /dev/null +++ b/src/test/java/com/genius/gitget/topic/controller/TopicControllerTest.java @@ -0,0 +1,127 @@ +package com.genius.gitget.topic.controller; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.global.file.service.FilesManager; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.repository.TopicRepository; +import com.genius.gitget.util.security.TokenTestUtil; +import com.genius.gitget.util.security.WithMockCustomUser; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +public class TopicControllerTest { + MockMvc mockMvc; + @Autowired + WebApplicationContext context; + @Autowired + TokenTestUtil tokenTestUtil; + + @Autowired + TopicRepository topicRepository; + @Autowired + FilesManager filesManager; + @Autowired + private ObjectMapper objectMapper; + + + @BeforeEach + public void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("토픽 상세 정보를 요청하면, 해당 토픽의 정보를 반환한다.") + public void 토픽_상세정보_요청() throws Exception { + Topic savedTopic = getSavedTopic(); + Long id = savedTopic.getId(); + + mockMvc.perform(get("/api/admin/topic/" + id).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("title")); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("토픽 리스트를 요청하면, 해당 토픽의 정보를 리스트로 반환한다.") + public void 토픽_리스트_요청() throws Exception { + getSavedTopic(); + getSavedTopic(); + getSavedTopic(); + + mockMvc.perform(get("/api/admin/topic") + .contentType(MediaType.APPLICATION_JSON) + .headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.numberOfElements").value(3)) + .andExpect(jsonPath("$.data.content[0].title").value("title")) + .andExpect(jsonPath("$.data.content[1].title").value("title")) + .andExpect(jsonPath("$.data.content[2].title").value("title")) + .andExpect(jsonPath("$.data.content[3].title").doesNotExist()); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("토픽 삭제 성공하면, 200 상태코드를 반환한다.") + public void 토픽_삭제_성공() throws Exception { + Topic savedTopic = getSavedTopic(); + Long id = savedTopic.getId(); + + mockMvc.perform(delete("/api/admin/topic/" + id).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.numberOfElements").doesNotExist()); + + Assertions.assertThat(topicRepository.findById(id)).isEmpty(); + } + + @Test + @WithMockCustomUser(role = Role.ADMIN) + @DisplayName("토픽 삭제 실패하면, 4xx 상태코드를 반환한다.") + public void 토픽_삭제_실패() throws Exception { + Topic savedTopic = getSavedTopic(); + Long id = savedTopic.getId(); + + mockMvc.perform(delete("/api/admin/topic/" + id + 1).headers(tokenTestUtil.createAccessHeaders())) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + + + private Topic getSavedTopic() { + Topic topic = topicRepository.save( + Topic.builder() + .title("title") + .notice("notice") + .description("description") + .tags("BE") + .pointPerPerson(100) + .build() + ); + return topic; + } +} diff --git a/src/test/java/com/genius/gitget/topic/repository/TopicRepositoryTest.java b/src/test/java/com/genius/gitget/topic/repository/TopicRepositoryTest.java new file mode 100644 index 00000000..899f220a --- /dev/null +++ b/src/test/java/com/genius/gitget/topic/repository/TopicRepositoryTest.java @@ -0,0 +1,193 @@ +package com.genius.gitget.topic.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.genius.gitget.topic.domain.Topic; +import jakarta.transaction.Transactional; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +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.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.Rollback; + +//@ExtendWith(SpringExtension.class) +//@DataJpaTest +@SpringBootTest +@Transactional +@Rollback +@DisplayName("TopicRepository") +public class TopicRepositoryTest { + Topic topicA, topicB; + + @Autowired + private TopicRepository topicRepository; + + @BeforeEach + public void setup() { + topicA = Topic.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .build(); + + topicB = Topic.builder() + .title("1일 2알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(300) + .build(); + } + + @Nested + @DisplayName("save 메서드는") + class Describe_save { + + @BeforeEach + public void init() { + topicRepository.deleteAll(); + } + + @Nested + @DisplayName("토픽 객체가 주어질 때") + class Context_with_a_topic { + + @Test +// it_returns_4XX_if_saving_the_obj_fails + @DisplayName("객체 저장에 성공하면 저장된 객체를 반환합니다.") + public void it_returns_the_saved_obj_if_saving_an_obj_succeeds() { + Topic savedTopic = topicRepository.save(topicA); + + assertEquals(topicA.getId(), savedTopic.getId()); + assertEquals(topicA.getTitle(), savedTopic.getTitle()); + assertEquals(topicA.getDescription(), savedTopic.getDescription()); + } + } + } + + + @Nested + @DisplayName("search 메서드는") + class Describe_search { + + @Nested + @DisplayName("조회 조건에 따라") + class Context_with_a_topic { + + @BeforeEach + public void prepare() { + topicRepository.save(topicA); + topicRepository.save(topicB); + } + + @Test + @DisplayName("토픽 전체를 반환합니다.") + public void it_returns_topic_obj_list() { + Page topics = topicRepository.findAllById(PageRequest.of(0, 5)); + int topicCount = 0; + + for (Topic topic : topics) { + if (topic != null) { + topicCount++; + } + } + + assertThat(topicCount).isEqualTo(2); + } + + @Test + @DisplayName("특정 토픽을 반환합니다.") + public void it_returns_topic_obj() { + Optional topic = topicRepository.findById(topicA.getId()); + + assertThat(topic.get().getTitle()).isEqualTo("1일 1알고리즘"); + } + } + } + + + @Nested + @DisplayName("update 메서드는") + class Describe_update { + + @Nested + @DisplayName("토픽 정보를 수정하려고 할 때") + class Context_with_a_topic { + + @BeforeEach + public void init() { + topicRepository.save(topicA); + } + + @Test + @DisplayName("생성된 인스턴스가 존재하면 description만 수정할 수 있고, 없다면 모든 항목을 수정할 수 있다.") + public void it_returns_updated_obj() { + + Topic topic = topicRepository.findById(topicA.getId()).orElse(null); + + boolean hasInstance = false; + if (!topic.getInstanceList().isEmpty()) { + hasInstance = true; + topic.updateExistInstance("(수정) 하루에 두 문제씩 문제를 해결합니다."); + } else { + topic.updateNotExistInstance("1일 2알고리즘", "(수정) 하루에 두 문제씩 문제를 해결합니다.", "CS", "유의사항", 30000); + } + + Topic savedTopic = topicRepository.save(topic); + + if (!hasInstance) { + assertEquals(topic.getId(), savedTopic.getId()); + assertEquals("(수정) 하루에 두 문제씩 문제를 해결합니다.", savedTopic.getDescription()); + } else { + assertEquals(topic.getId(), savedTopic.getId()); + assertEquals("(수정) 하루에 두 문제씩 문제를 해결합니다.", savedTopic.getDescription()); + assertEquals(30000, savedTopic.getPointPerPerson()); + } + } + } + } + + + @Nested + @DisplayName("delete 메서드는") + class Describe_delete { + + @Nested + @DisplayName("삭제하려는 토픽 ID가 주어질 때") + class Context_with_a_topic { + + @BeforeEach + public void init() { + topicRepository.save(topicA); + } + + @Test + @DisplayName("객체가 성공적으로 삭제되면, 다시 조회할 수 없다.") + public void it_cannot_be_retrieved_once_an_obj_is_successfully_deleted() { + Topic topic = topicRepository.findById(topicA.getId()).orElse(null); + assert topic != null; + topicRepository.delete(topic); + Topic findTopic = topicRepository.findById(topicA.getId()).orElse(null); + Assertions.assertThrows(NullPointerException.class, () -> { + findTopic.getId(); + }); + } + + @Test + @DisplayName("DB에 해당 객체가 없으면, 삭제할 수 없다.") + public void it_cannot_be_deleted_if_the_obj_does_not_exist() { + Assertions.assertThrows(Exception.class, () -> { + Topic topic = topicRepository.findById(topicB.getId()).orElse(null); + topicRepository.delete(topic); + }); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/topic/service/TopicFacadeTest.java b/src/test/java/com/genius/gitget/topic/service/TopicFacadeTest.java new file mode 100644 index 00000000..63e68b63 --- /dev/null +++ b/src/test/java/com/genius/gitget/topic/service/TopicFacadeTest.java @@ -0,0 +1,139 @@ +package com.genius.gitget.topic.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.genius.gitget.global.util.exception.BusinessException; +import com.genius.gitget.topic.domain.Topic; +import com.genius.gitget.topic.dto.TopicCreateRequest; +import com.genius.gitget.topic.dto.TopicDetailResponse; +import com.genius.gitget.topic.dto.TopicUpdateRequest; +import com.genius.gitget.topic.facade.TopicFacade; +import jakarta.transaction.Transactional; +import org.assertj.core.api.Assertions; +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.context.SpringBootTest; + +@SpringBootTest +@Transactional +public class TopicFacadeTest { + Topic topicA, topicB; + String fileType; + + @Autowired + TopicFacade topicFacade; + + @BeforeEach + public void setup() { + topicA = Topic.builder() + .title("1일 1알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(100) + .build(); + + topicB = Topic.builder() + .title("1일 2알고리즘") + .description("하루에 한 문제씩 문제를 해결합니다.") + .tags("BE, FE, CS") + .pointPerPerson(300) + .build(); + + fileType = "topic"; + } + + private TopicCreateRequest getTopicCreateRequest() { + return TopicCreateRequest.builder() + .title(topicA.getTitle()) + .description(topicA.getDescription()) + .tags(topicA.getTags()) + .pointPerPerson(topicA.getPointPerPerson()) + .notice(topicA.getNotice()) + .build(); + } + + private TopicUpdateRequest getTopicUpdateRequest(String title, String description, String tags, int pointPerPersion, + String notice) { + return TopicUpdateRequest.builder() + .title(title) + .description(description) + .tags(tags) + .pointPerPerson(pointPerPersion) + .notice(notice).build(); + } + + @Nested + @DisplayName("토픽 생성 메서드는") + class Describe_topic_create { + + @Nested + @DisplayName("topicCreateRequestDto가 들어오면") + class Context_with_a_topicCreateRequestDto { + + @Test + @DisplayName("토픽을 생성한다.") + public void it_returns_2XX_if_the_topic_was_created_successfully() { + TopicCreateRequest topicCreateRequest = getTopicCreateRequest(); + + Long savedTopicId = topicFacade.create(topicCreateRequest); + + TopicDetailResponse topicById = topicFacade.findOne(savedTopicId); + + Assertions.assertThat(topicById.title()).isEqualTo(topicCreateRequest.title()); + } + } + } + + @Nested + @DisplayName("토픽 수정 메서드는") + class Describe_topic_update { + + @Nested + @DisplayName("TopicUpdateRequestDto가 들어오면") + class Context_with_a_TopicUpdateRequestDto { + + @Test + @DisplayName("토픽 내용을 수정한다.") + public void it_returns_2XX_if_the_topic_is_modified() { + TopicCreateRequest topicCreateRequest = getTopicCreateRequest(); + Long savedTopicId = topicFacade.create(topicCreateRequest); + + TopicUpdateRequest topicUpdateRequest = getTopicUpdateRequest("1일 5커밋", topicA.getDescription(), + topicA.getTags(), topicA.getPointPerPerson(), topicA.getNotice()); + + topicFacade.update(savedTopicId, topicUpdateRequest); + + TopicDetailResponse findTopic = topicFacade.findOne(savedTopicId); + Assertions.assertThat(findTopic.title()).isEqualTo("1일 5커밋"); + } + } + } + + @Nested + @DisplayName("토픽 삭제 메서드는") + class Describe_topic_delete { + + @Nested + @DisplayName("삭제할 토픽 Id가 주어질 때") + class Context_with_a_TopicUpdateRequestDto { + + @Test + @DisplayName("해당 토픽을 삭제한다.") + public void it_returns_2XX_if_the_topic_is_successfully_deleted() throws Exception { + TopicCreateRequest topicCreateRequest = getTopicCreateRequest(); + Long savedTopicId = topicFacade.create(topicCreateRequest); + + topicFacade.delete(savedTopicId); + + try { + topicFacade.findOne(savedTopicId); + } catch (BusinessException e) { + assertEquals("해당 토픽을 찾을 수 없습니다.", e.getMessage()); + } + } + } + } +} diff --git a/src/test/java/com/genius/gitget/util/certification/CertificationFactory.java b/src/test/java/com/genius/gitget/util/certification/CertificationFactory.java new file mode 100644 index 00000000..572a3717 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/certification/CertificationFactory.java @@ -0,0 +1,58 @@ +package com.genius.gitget.util.certification; + +import com.genius.gitget.challenge.certification.domain.CertificateStatus; +import com.genius.gitget.challenge.certification.domain.Certification; +import com.genius.gitget.challenge.certification.util.DateUtil; +import com.genius.gitget.challenge.participant.domain.Participant; +import java.time.LocalDate; + +public class CertificationFactory { + public static Certification create(CertificateStatus status, LocalDate certificatedAt, + Participant participant) { + int attempt = DateUtil.getAttemptCount(participant.getStartedDate(), certificatedAt); + Certification certification = Certification.builder() + .certificationStatus(status) + .currentAttempt(attempt) + .certificatedAt(certificatedAt) + .certificationLinks("certificationLink") + .build(); + certification.setParticipant(participant); + return certification; + } + + public static Certification createNotYet(Participant participant, LocalDate certificatedAt) { + int attempt = DateUtil.getAttemptCount(participant.getStartedDate(), certificatedAt); + Certification certification = Certification.builder() + .certificationStatus(CertificateStatus.NOT_YET) + .currentAttempt(attempt) + .certificatedAt(certificatedAt) + .certificationLinks(null) + .build(); + certification.setParticipant(participant); + return certification; + } + + public static Certification createCertificated(Participant participant, LocalDate certificatedAt) { + int attempt = DateUtil.getAttemptCount(participant.getStartedDate(), certificatedAt); + Certification certification = Certification.builder() + .certificationStatus(CertificateStatus.CERTIFICATED) + .currentAttempt(attempt) + .certificatedAt(certificatedAt) + .certificationLinks("certificationLink") + .build(); + certification.setParticipant(participant); + return certification; + } + + public static Certification createPassed(Participant participant, LocalDate certificatedAt) { + int attempt = DateUtil.getAttemptCount(participant.getStartedDate(), certificatedAt); + Certification certification = Certification.builder() + .certificationStatus(CertificateStatus.PASSED) + .currentAttempt(attempt) + .certificatedAt(certificatedAt) + .certificationLinks(null) + .build(); + certification.setParticipant(participant); + return certification; + } +} diff --git a/src/test/java/com/genius/gitget/util/file/FileTestUtil.java b/src/test/java/com/genius/gitget/util/file/FileTestUtil.java new file mode 100644 index 00000000..7126b8c8 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/file/FileTestUtil.java @@ -0,0 +1,55 @@ +package com.genius.gitget.util.file; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class FileTestUtil { + public static MultipartFile getMultipartFile(String filename) { + return new MultipartFile() { + @Override + public String getName() { + return filename; + } + + @Override + public String getOriginalFilename() { + return filename + ".png"; + } + + @Override + public String getContentType() { + return "png"; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public long getSize() { + return 0; + } + + @Override + public byte[] getBytes() throws IOException { + return new byte[0]; + } + + @Override + public InputStream getInputStream() throws IOException { + return null; + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + + } + }; + } +} diff --git a/src/test/java/com/genius/gitget/util/file/FileTest_README.md b/src/test/java/com/genius/gitget/util/file/FileTest_README.md new file mode 100644 index 00000000..3aef2928 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/file/FileTest_README.md @@ -0,0 +1,51 @@ +### File 테스트 방법 + +통합 테스트를 진행한다면 Service 단의 메서드들을 호출하여 진행 +Service 단의 매개변수에 `MultipartFile`이 전달된 후, 서비스 단에서 이를 활용하기 때문에 +MultipartFile만 잘 생성하면 됨. + +#### 1. FileTestUtil을 통해 MultipartFile 받아오기 + +`FileTestUtil.getMultipartFile("파일 이름")`을 통해 `MultiPart` 객체를 받을 수 있다. +매개 변수로는 `filename`을 전달받는데, "sky", "aws_image"와 같은 값을 전달하면, +내부적으로 "sky.png", "aws_image.png" 로 저장된다. + +`InstanceService`에 파일을 전달하여 인스턴스 생성 시의 코드 + +```java +Long savedInstanceId = instanceService.createInstance(instanceCreateRequest, + FileTestUtil.getMultipartFile("name"), fileType); +``` + +
+ +#### 2. Files 객체를 단독으로 생성하고 싶을 때 - FilesService 이용 + +`MultipartFile`을 통해 토픽/인스턴스의 생성/수정 하는 방법이 아니라, `Files` 엔티티를 만들고 싶을 때에는 +`FilesService`의 코드를 사용해야 한다. + +1. `FileTestUtil.getMultipartFile("파일이름")`을 통해 MultipartFile을 반환받는다. +2. `public Files uploadFile(MultipartFile receivedFile, String typeStr)`의 매개변수로 전달하면, + FilesRepository를 통해 저장한 Files 엔티티를 반환받을 수 있다. +3. 이후 Topic, Instance, User의 `setFiles`를 통해 연관관계를 설정하면 된다. + +`FileUtilTest`에서 작성한 테스트 코드의 예시이다. +저장 이후 다시 encoding 해도 에러가 발생하지 않는다. + +```java + +@Test +@DisplayName("FileTestUtil을 통해 받은 MultipartFile을 통해 인코딩 파일을 받을 수 있다") +public void should_getEncodedFiles() { + //given + MultipartFile multipartFile = FileTestUtil.getMultipartFile("filename"); + Files files = filesService.uploadFile(multipartFile, "topic"); + + //when + String encoded = FileUtil.encodedImage(files); + + //then + log.info(encoded); +} + +``` \ No newline at end of file diff --git a/src/test/java/com/genius/gitget/util/instance/InstanceFactory.java b/src/test/java/com/genius/gitget/util/instance/InstanceFactory.java new file mode 100644 index 00000000..118c559c --- /dev/null +++ b/src/test/java/com/genius/gitget/util/instance/InstanceFactory.java @@ -0,0 +1,49 @@ +package com.genius.gitget.util.instance; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.instance.domain.Progress; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class InstanceFactory { + public static Instance createByInfo(LocalDate started, Progress progress) { + return Instance.builder() + .progress(progress) + .startedDate(started.atTime(0, 0)) + .completedDate(started.plusDays(10).atTime(1, 0)) + .build(); + } + + /** + * LocalDate.now()를 기준으로 PREACTIVITY(시작 전) 인스턴스 생성 후 반환 + */ + public static Instance createPreActivity(int duration) { + return Instance.builder() + .progress(Progress.PREACTIVITY) + .startedDate(LocalDateTime.now().plusDays(1)) + .completedDate(LocalDateTime.now().plusDays(duration + 1)) + .build(); + } + + /** + * LocalDate.now()를 기준으로 진행 중인 인스턴스 생성 후 반환 + */ + public static Instance createActivity(int duration) { + return Instance.builder() + .progress(Progress.ACTIVITY) + .startedDate(LocalDateTime.now()) + .completedDate(LocalDateTime.now().plusDays(duration)) + .build(); + } + + /** + * LocalDate.now()를 기준으로 완료된 인스턴스 생성 후 반환 + */ + public static Instance createDone(int duration) { + return Instance.builder() + .progress(Progress.DONE) + .startedDate(LocalDateTime.now().minusDays(duration - 1)) + .completedDate(LocalDateTime.now().minusDays(1)) + .build(); + } +} diff --git a/src/test/java/com/genius/gitget/util/participant/ParticipantFactory.java b/src/test/java/com/genius/gitget/util/participant/ParticipantFactory.java new file mode 100644 index 00000000..ba66082f --- /dev/null +++ b/src/test/java/com/genius/gitget/util/participant/ParticipantFactory.java @@ -0,0 +1,84 @@ +package com.genius.gitget.util.participant; + +import com.genius.gitget.challenge.instance.domain.Instance; +import com.genius.gitget.challenge.participant.domain.JoinResult; +import com.genius.gitget.challenge.participant.domain.JoinStatus; +import com.genius.gitget.challenge.participant.domain.Participant; +import com.genius.gitget.challenge.participant.domain.RewardStatus; +import com.genius.gitget.challenge.user.domain.User; + +public class ParticipantFactory { + /** + * 시작 전인 참여 정보 엔티티 만들어서 반환 + * user, instance를 받아서 연관관계 설정 후 반환 + */ + public static Participant createPreActivity(User user, Instance instance) { + Participant participant = Participant.builder() + .joinResult(JoinResult.READY) + .joinStatus(JoinStatus.YES) + .build(); + participant.setUserAndInstance(user, instance); + participant.updateRepository("targetRepo"); + + return participant; + } + + /** + * 진행 중인 참여 정보 엔티티 만들어서 반환 + * user, instance를 받아서 연관관계 설정 후 반환 + */ + public static Participant createProcessing(User user, Instance instance) { + Participant participant = Participant.builder() + .joinResult(JoinResult.PROCESSING) + .joinStatus(JoinStatus.YES) + .build(); + participant.setUserAndInstance(user, instance); + participant.updateRepository("targetRepo"); + + return participant; + } + + /** + * 참여 정보에 대해 JoinResult(참여 결과 - 시작전, 진행중, 실패, 성공) 설정 후 반환 + * user, instance를 받아서 연관관계 설정 후 반환 + */ + public static Participant createByJoinResult(User user, Instance instance, JoinResult joinResult) { + Participant participant = Participant.builder() + .joinResult(joinResult) + .joinStatus(JoinStatus.YES) + .build(); + participant.setUserAndInstance(user, instance); + participant.updateRepository("targetRepo"); + + return participant; + } + + /** + * 챌린지가 끝난 참여 정보에 대해, RewardStatus(보상 수령 상태)에 대한 값을 설정 후 반환 + * user, instance를 받아서 연관관계 설정 후 반환 + */ + public static Participant createByRewardStatus(User user, Instance instance, JoinResult joinResult, + RewardStatus rewardStatus) { + Participant participant = Participant.builder() + .joinResult(joinResult) + .joinStatus(JoinStatus.YES) + .rewardStatus(rewardStatus) + .build(); + participant.setUserAndInstance(user, instance); + participant.updateRepository("targetRepo"); + + return participant; + } + + public static Participant createQuit(User user, Instance instance, JoinResult joinResult) { + Participant participant = Participant.builder() + .joinResult(joinResult) + .joinStatus(JoinStatus.NO) + .rewardStatus(RewardStatus.NO) + .build(); + participant.setUserAndInstance(user, instance); + participant.updateRepository("targetRepo"); + + return participant; + } +} diff --git a/src/test/java/com/genius/gitget/util/security/TokenTestUtil.java b/src/test/java/com/genius/gitget/util/security/TokenTestUtil.java new file mode 100644 index 00000000..966c3195 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/security/TokenTestUtil.java @@ -0,0 +1,81 @@ +package com.genius.gitget.util.security; + +import static com.genius.gitget.global.security.constants.JwtRule.ACCESS_PREFIX; +import static com.genius.gitget.global.security.constants.JwtRule.REFRESH_PREFIX; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.security.constants.JwtRule; +import com.genius.gitget.global.security.domain.UserPrincipal; +import com.genius.gitget.global.security.service.JwtFacadeService; +import jakarta.servlet.http.Cookie; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Component +@RequiredArgsConstructor +public class TokenTestUtil { + private final JwtFacadeService jwtFacade; + + public Cookie createAccessHeader() { + UserPrincipal userPrincipal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication() + .getPrincipal(); + User user = userPrincipal.getUser(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + String accessCookie = jwtFacade.generateAccessToken(httpServletResponse, user); + return new Cookie(ACCESS_PREFIX.getValue(), accessCookie); + } + + public HttpHeaders createAccessHeaders() { + UserPrincipal userPrincipal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication() + .getPrincipal(); + User user = userPrincipal.getUser(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + String accessToken = jwtFacade.generateAccessToken(httpServletResponse, user); + String bearerAccess = ACCESS_PREFIX.getValue() + accessToken; + + MultiValueMap headers = new LinkedMultiValueMap<>(); + headers.put(JwtRule.ACCESS_HEADER.getValue(), Collections.singletonList(bearerAccess)); + return HttpHeaders.readOnlyHttpHeaders(headers); + } + + public String createAccessToken() { + UserPrincipal userPrincipal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication() + .getPrincipal(); + User user = userPrincipal.getUser(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + return jwtFacade.generateAccessToken(httpServletResponse, user); + } + + public Cookie createRefreshCookie() { + UserPrincipal userPrincipal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication() + .getPrincipal(); + User user = userPrincipal.getUser(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + String refreshCookie = jwtFacade.generateRefreshToken(httpServletResponse, user); + return new Cookie(REFRESH_PREFIX.getValue(), refreshCookie); + } + + public String createRefreshToken() { + UserPrincipal userPrincipal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication() + .getPrincipal(); + User user = userPrincipal.getUser(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + return jwtFacade.generateRefreshToken(httpServletResponse, user); + } +} diff --git a/src/test/java/com/genius/gitget/util/security/WithMockCustomUser.java b/src/test/java/com/genius/gitget/util/security/WithMockCustomUser.java new file mode 100644 index 00000000..33f906fa --- /dev/null +++ b/src/test/java/com/genius/gitget/util/security/WithMockCustomUser.java @@ -0,0 +1,26 @@ +package com.genius.gitget.util.security; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.global.security.constants.ProviderInfo; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.springframework.security.test.context.support.WithSecurityContext; + +@Retention(value = RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class) +public @interface WithMockCustomUser { + + ProviderInfo providerInfo() default ProviderInfo.GITHUB; + + String identifier() default "identifier"; + + String nickname() default "nickname"; + + String interest() default "BE,FE"; + + String information() default "information"; + + Role role() default Role.USER; + + String profileName() default "profile"; +} diff --git a/src/test/java/com/genius/gitget/util/security/WithMockCustomUserSecurityContextFactory.java b/src/test/java/com/genius/gitget/util/security/WithMockCustomUserSecurityContextFactory.java new file mode 100644 index 00000000..f1eaa450 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/security/WithMockCustomUserSecurityContextFactory.java @@ -0,0 +1,65 @@ +package com.genius.gitget.util.security; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.challenge.user.dto.SignupRequest; +import com.genius.gitget.challenge.user.facade.UserFacade; +import com.genius.gitget.challenge.user.repository.UserRepository; +import com.genius.gitget.global.security.dto.SignupResponse; +import com.genius.gitget.global.security.service.CustomUserDetailsService; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +@RequiredArgsConstructor +@Slf4j +public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory { + private final UserRepository userRepository; + private final UserFacade userFacade; + private final CustomUserDetailsService customUserDetailsService; + + @Value("${github.yeon-githubId}") + private String githubId; + + @Override + public SecurityContext createSecurityContext(WithMockCustomUser customUser) { + String identifier = githubId; + if (!Objects.equals(customUser.identifier(), "identifier")) { + identifier = customUser.identifier(); + } + + User user = User.builder() + .providerInfo(customUser.providerInfo()) + .identifier(identifier) + .role(Role.NOT_REGISTERED) + .build(); + + SignupRequest signupRequest = SignupRequest.builder() + .identifier(identifier) + .interest(List.of("FE", "BE")) + .nickname(customUser.nickname()) + .information(customUser.information()) + .build(); + + User savedUser = userRepository.save(user); + SignupResponse signupResponse = userFacade.signup(signupRequest); + savedUser.updateRole(customUser.role()); + + Long userId = signupResponse.userId(); + UserDetails principal = customUserDetailsService.loadUserByUsername(String.valueOf(userId)); + Authentication auth = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), + principal.getAuthorities()); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(auth); + return securityContext; + } +} diff --git a/src/test/java/com/genius/gitget/util/store/StoreFactory.java b/src/test/java/com/genius/gitget/util/store/StoreFactory.java new file mode 100644 index 00000000..a038ec68 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/store/StoreFactory.java @@ -0,0 +1,25 @@ +package com.genius.gitget.util.store; + +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.store.item.domain.Item; +import com.genius.gitget.store.item.domain.ItemCategory; +import com.genius.gitget.store.item.domain.Orders; + +public class StoreFactory { + public static Item createItem(ItemCategory itemCategory) { + return Item.builder() + .identifier(10) + .itemCategory(itemCategory) + .cost(100) + .name(itemCategory.getName()) + .details("details") + .build(); + } + + public static Orders createOrders(User user, Item item, ItemCategory itemCategory, int count) { + Orders orders = Orders.of(count, itemCategory); + orders.setItem(item); + orders.setUser(user); + return orders; + } +} diff --git a/src/test/java/com/genius/gitget/util/user/UserFactory.java b/src/test/java/com/genius/gitget/util/user/UserFactory.java new file mode 100644 index 00000000..148138f2 --- /dev/null +++ b/src/test/java/com/genius/gitget/util/user/UserFactory.java @@ -0,0 +1,48 @@ +package com.genius.gitget.util.user; + +import com.genius.gitget.challenge.user.domain.Role; +import com.genius.gitget.challenge.user.domain.User; +import com.genius.gitget.global.security.constants.ProviderInfo; + +public class UserFactory { + + public static User createByInfo(String identifier, Role role) { + return User.builder() + .role(role) + .providerInfo(ProviderInfo.GITHUB) + .identifier(identifier) + .information("information") + .tags("BE,FE") + .build(); + } + + public static User createUser() { + return User.builder() + .role(Role.USER) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("githubId") + .information("information") + .tags("BE,FE") + .build(); + } + + public static User createAdmin() { + return User.builder() + .role(Role.ADMIN) + .nickname("nickname") + .providerInfo(ProviderInfo.GITHUB) + .identifier("githubId") + .information("information") + .tags("BE,FE") + .build(); + } + + public static User createUnregistered(String identifier) { + return User.builder() + .identifier(identifier) + .role(Role.NOT_REGISTERED) + .providerInfo(ProviderInfo.GITHUB) + .build(); + } +} diff --git a/tatus b/tatus new file mode 100644 index 00000000..dbf3b16e --- /dev/null +++ b/tatus @@ -0,0 +1,1426 @@ +commit 0bf88386d2c54e1514492811010360948868e0fd (HEAD -> feat/52-payment) +Merge: f7e3001 50a4997 +Author: kimdozzi +Date: Thu Feb 15 01:30:27 2024 +0900 + + chore: from main to feat/52-payment merge 완료 + +commit 50a499732c9cf560c7113d1d0e40d41eefe97dcb (origin/main, origin/HEAD, main) +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Feb 15 01:16:33 2024 +0900 + + chore: package 구조 변경 + + - home 패키지 내의 controller, service, dto를 instance 패키지로 이동 + +commit c964d56d0e586ee958ae0492a58b954c6ea9eff3 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Feb 15 01:13:50 2024 +0900 + + chore: Querydsl 관련 build.gradle 수정 + +commit 8b15712bfe48dca94d64d8020e1adf5f6d7bef63 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Feb 15 00:33:39 2024 +0900 + + chore: gitignore 추가 설정 + +commit 30449befceef678627c20cce1dfd96b30c829865 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Feb 14 14:29:31 2024 +0900 + + hotfix: instance 생성 시, 경로가 없어 예외를 던지는 버그 픽스 + +commit 40159008d3b81bfa8f9d855fd2caac87dcd79995 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Wed Feb 14 10:04:10 2024 +0900 + + [BUG] Topic 삭제 요청 시, 삭제가 되지 않는 버그 (#67) + + * fix: topic 삭제 시 예외가 발생하던 버그 픽스 + + - topic 삭제 가능 조건에서 삭제 요청을 했을 때, 삭제가 되지 않고 예외가 발생하던 버그 픽스 + - 영속성 전이(cascade) 추가로 인해 발생하던 문제임을 인지 후 수정 + + * refactor: File 로직 예외 추가 처리 + + - File 시스템과 관련된 로직에서 따로 처리하지 않았던 부분에 대해 try-catch문을 통해 명시적으로 처리 + + * feat: instance 생성 로직 보강 + + - Topic에 등록된 이미지를 사용하기 위해, instance 생성 시 이미지를 별도로 전달하지 않은 경우에 대해 처리 로직 추가 + - FileUtil에 복사 관련 로직 추가 구현 + +commit 89a82600dcb6ac0c764607dc22482dc5db985e5c +Author: DoHyung Kim +Date: Tue Feb 13 22:44:16 2024 +0900 + + [FEAT] 동적쿼리를 적용한 검색 기능 개발 (#65) + + * chore: testBaseUtil 개발 중 패키지 구조 변경으로 인한 커밋 + + * feat: Home 검색 기능 개발 + + * refactor: search dto 네임 변경 + + * test: 단위 테스트 수행 + + - controller test 중 mongodb 이슈 발생 -> 해결요망 + - 검색 기능 postman test 완료 + + * test: 검색 기능 단위 테스트 + + * feat: 검색 조건에 따른 키워드 검색 기능 개발 + + - stringToEnum converter 재정의 + - 스프링 빈으로 등록하여 생성한 컨버터 사용 적용 + - 검색 조건에 따른 각 계층별 코드 작성 + + * refactor: 불필요한 코드 제거 + + * refactor: 코드 리펙토링 + + * test: topicController mongodb issue test + + * chore: Querydsl dependency 추가 + + * test: querydsl test + + * test: querydsl 적용 테스트 + + * feat: querydsl dto 생성 + + - querydsl로 작성한 프로젝션을 받아오기 위한 dto + + * test: querydsl 별도의 dto를 사용한 instance와 files join table 테스트 + + * test: 프로젝션 결과반환 - 생성자 방식 사용 + + * test: 동적쿼리 - booleanBuilder 테스트 + + * test: 동적쿼리 - 다중 where + + - composition 가능한 장점이 있음 + + * test: 수정, 삭제 배치쿼리 + + - bulkUpdate + - bulkAdd + - bulkDelete + + * feat: 동적 쿼리 적용 및 querydsl 페이징 연동 + + * feat: 검색 조건과 챌린지 진행 현황에 따른 검색 기능 개발 + + - querydsl 적용 + - 사용자 정의 리포지토리 적용 + - BooleamBuilder 적용으로 성능 최적화 + - instanceSearchService 코드 수정 및 개선 + - progress entity ALL 제거 + - 검색 기능 repository 테스트 코드 작성 + + * feat: 동적쿼리를 위한 instanceSearchService 로직 개발 + + - instanceSearchService 코드 개선 + - ErrorCode 추가 + - postmand api 테스트 완료 + + * refactor: 코드 리펙토링 + +commit f7e3001fae9604e4c5f3a5343d14472ff8b601a6 +Author: kimdozzi +Date: Mon Feb 12 19:12:06 2024 +0900 + + test: 결제 검증을 위한 테스트 환경 구축 + +commit e5418de60708f437ff1e01db7685f03aaed43261 +Author: kimdozzi +Date: Mon Feb 12 15:27:01 2024 +0900 + + test: 사용자 access_token 정보 취득 테스트 + +commit 6cd7dcf0329fa5f516fd5cf7892fbf4a104d665e +Author: kimdozzi +Date: Mon Feb 12 15:26:37 2024 +0900 + + chore: iamport dependency 추가 + +commit 6d4f561fdd7ffba2e10bac1e1d133eee40d583ce +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Feb 9 23:27:07 2024 +0900 + + hotfix: 홈화면 API에서 인스턴스 식별자를 반환하지 않는 버그 픽스 + +commit e0791f510aaa7b0b2690259447be981a7e35e030 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Wed Feb 7 01:50:51 2024 +0900 + + feat: 엔티티 영속성 전이 설정 (#64) + +commit 15c36249e54e62c35bc19715f6acf3c9d03a397e +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sat Feb 3 15:23:09 2024 +0900 + + test: 로직 변경으로 인해 돌아가지 않던 테스트 코드 수정 + + - FileTestUtil 클래스 작성: MultiFile을 반환하는 정적 메서드 추가 + - Topic, Instance 로직 변경으로 인해 돌아가지 않던 테스트 코드 추가 + - DTO에 빌더패턴 적용 + +commit 2fe76bd70d15585f99b4d43572df6c41107f543b +Author: DoHyung Kim +Date: Sat Feb 3 15:22:41 2024 +0900 + + feat: 테스트 중 이슈 발견 및 해결 (#62) + + - 토픽이 인스턴스를 가질 때 해당 토픽을 삭제하려 할 때의 예외처리 + - ErrorCode 추가 + +commit 63e2e807f9bf0cf0c4b4c03738ccae7e5c8ceebd +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Sat Feb 3 10:27:23 2024 +0900 + + [HOTFIX] JWT Filter에서 예외 발생 시 처리하지 못하는 로직 수정 (#60) + + * feat: OAuth 로그인 성공 시 리다이렉트 주소 변경 + + - OAuth 로그인 성공 시, 토큰 발급 URL로 리다이렉트하도록 변경 + + * feat: JwtAuthenticationFilter 예외를 처리할 필터 추가 구현 + +commit fa331c20c7f43a9d29a1ecc992bb4ebeac838bc2 +Author: DoHyung Kim +Date: Fri Feb 2 23:28:34 2024 +0900 + + [FEAT] 어드민 페이지 파일 api 적용 (#61) + + * chore: enum converter global 패키지로 이동 + + * feat: 어드민 페이지 topic - file 적용 + + * feat: 어드민 페이지 instance - file 적용 + + * feat: admin file api 적용 완료 + +commit 4b96a350677d1dfdbf8ef6803bb75016e5050241 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Fri Feb 2 14:54:10 2024 +0900 + + [REFACTOR] FileType Enum 예외처리 로직 보강 (#59) + + * refactor: 프로덕션 코드 변경으로 인한 테스트 코드 수정 + + * refactor: FileType 정적 팩토리 메서드 예외 추가 처리 + + - 소문자 외에 대문자로 왔을 때에도 처리가 가능하도록 수정 + - 메서드명 오타 수정 + + * fix: Files 삭제 시, DB의 값을 삭제되지 않는 버그 픽스 + + * chore: 프로덕션 패키지 구조에 맞게 테스트 코드 패키지 구조 변경 + +commit 872120be8d6d0bd96ba1d21251426cc5123dc6f6 (origin/refactor/42-constructor) +Author: DoHyung Kim +Date: Thu Feb 1 23:13:49 2024 +0900 + + hotfix: Instance entity 이름 변경 (#56) + +commit df2feaacf6c61dfd6129a9388b9efdee76da64d5 +Merge: 2cf298f 5d0aef2 +Author: kimdozzi +Date: Thu Feb 1 23:04:39 2024 +0900 + + Merge branch 'main' of https://github.com/TeamTheGenius/TeamTheGenius_Server + +commit 2cf298f23f6f5b53c9caa29f1be938646d957a79 +Author: kimdozzi +Date: Thu Feb 1 23:04:12 2024 +0900 + + hotfix: 충돌 코드 삭제 + +commit 5d0aef2b68a3ae2399eb505da6c1dfdc48b8c90b +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Feb 1 14:50:31 2024 +0900 + + !HOTFIX: logout API endpoint 변경 + + - /api/logout 에서 /api/auth/logout으로 변경 + +commit 7ac23d50981c81aa4f79827002b13f73f1e1a51c +Author: kimdozzi +Date: Wed Jan 31 19:11:55 2024 +0900 + + test: 불필요한 test class 제거 + +commit e93afa4a88f4147b727879359c47f32cdadfe570 +Author: kimdozzi +Date: Wed Jan 31 19:08:36 2024 +0900 + + refactor: instanceService 누락된 코드 추가 + +commit 4e84a310c1b34830d035cdb3b971ab5f60e69836 +Merge: ad32382 a957fb5 +Author: kimdozzi +Date: Wed Jan 31 19:07:23 2024 +0900 + + Merge branch 'main' of https://github.com/TeamTheGenius/TeamTheGenius_Server + +commit ad3238240a9898c28f6c444f54e95f2633d6a134 +Author: kimdozzi +Date: Wed Jan 31 19:07:15 2024 +0900 + + refactor: 불필요한 주석 제거 + +commit a957fb5c92de8d0db9d1f62946beff33bb30398a +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 31 18:39:16 2024 +0900 + + !HOTFIX: 컴파일 에러 해결 + +commit 64b18a0b23796ab4448844f03f4e9877074002b4 (origin/feat/52-payment) +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 30 23:01:18 2024 +0900 + + [FEAT] 홈 화면 - 추천/인기/신규 기능 개발 (#50) + + * Squashed commit of the following: + + commit 77f54caa2f77b9ce040738b2a2f642b03a84d1d8 + Author: SSung023 <50323157+SSung023@users.noreply.github.com> + Date: Fri Jan 26 16:33:27 2024 +0900 + + chore: 패키지 구조 변경 + + - admin 패키지: 관리자 관련 기능과 밀접한 기능들 + - challenge 패키지: 사용자 관련 기능과 밀접한 기능들 + - global 패키지: file, security, util 같은 서비스 전체에 영향을 줄 수 있는 기능들 + + commit 8929e06e2cbb61717b00f76ae08af526d09a2fee + Author: HEY <50323157+SSung023@users.noreply.github.com> + Date: Fri Jan 26 16:21:42 2024 +0900 + + [TEST] JWT, 회원가입 로직 테스트 코드 추가 (#47) + + * Squashed commit of the following: + + commit 025fd0c5faf4a2d6ab159f4b0a95c8c7cde751a3 + Author: SSung023 <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 23:15:25 2024 +0900 + + !HOTFIX: conflict resolve 해결 + + commit d2f1db695856489b13de47accb700c9432051ff3 + Author: HEY <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 23:14:14 2024 +0900 + + [FIX] JWT 재발급 관련 버그 픽스 (#45) + + * fix: access-token 재발급 안되는 버그 수정 + + * fix: 예외 처리 로직 추가 및 무한 리다이렉션 버그 픽스 + + - Cookie로부터 토큰을 얻을 때, cookie가 비어있을 때 예외 처리 로직 추가 + - JWT 토큰 요청 시, 사용자의 권한이 NOT_REGISTERED(가입 이전)이라면 JWT 토큰 발급 거부 로직 추가 + - refresh-token이 비어있는 경우 예외 처리 + + * chore: test 코드 정리 + + commit 42f6e36ae95189025342ee0730485d7c6e37cd20 + Author: SSung023 <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 21:43:28 2024 +0900 + + !HOTFIX: 회원가입 기능 핫픽스 + + - 회원가입 완료 후 반환하는 Response 객체의 구조 변경 + + commit f9a0e3320212a76c1fc33f291c4146b93d81963a + Author: HEY <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 18:09:04 2024 +0900 + + Update issue templates + + * test: JwtService에 대한 테스트 코드 추가 + + * test: JWT 테스트 코드 추가 + + * test: 회원가입 관련 테스트 코드 추가 + + * feat: InstanceRepository에 추천 인스턴스들을 받는 메서드 추가 + + - Instance 엔티티의 '참여자 수' 필드 추가 및 업데이트 메서드 추가 + - InstanceRepository에 조건에 맞는 추천 인스턴스들을 받아오는 메서드 추가 + - 추천 인스턴스를 받아오는 Repository 테스트 코드 추가 + + * chore: User의 관심사 필드의 이름 변경 + + User의 관심사 필드의 이름을 interest에서 tags로 변경하여, 다른 엔티티들과 통일 + + * refactor: 추천 인스턴스의 대상을 진행 중인 인스턴스로 변경 + + * feat: 추천 인스턴스를 반환하는 서비스 로직 구현 및 테스트 코드 추가 + + - 추천 인스턴스 페이징용 DTO인 RecommendPagingResponse 작성 + - 추천 인스턴스 결과를 페이징 형식으로 반환 + + * feat: 홈 화면의 추천 챌린지 추천 컨트롤러 개발 + + - 컨트롤러 개발 및 테스트 코드 작성 + + * refactor: 추천 챌린지의 대상을 '시작 전'으로 변경 + + * chore: DTO 이름 및 테스트 코드 수정 + + * feat: 신규 & 인기 인스턴스 레포지토리 구현 및 테스트 코드 추가 + + * refactor: 파일 시스템 예외 상황 처리 + + - 인스턴스의 경우 파일이 존재하지 않을 수 있으므로, Optional을 반환하도록 변경 + - FileResponse에서 파일이 존재하지 않는 경우에 호출할 팩토리 메서드 추가 + + * feat: 홈 화면의 인기/신규 기능 개발 + + * refactor: 파일 시스템 구조 변경 + + - FilesController: 파일 업로드 시, DTO와 이미지를 같이 받을 수 있는 예시로 변경 + - FileUtil: 메서드들을 모두 static으로 변경 + - FilesService: UPLOAD_PATH 관리 위치 변경 + - FileResponse: 팩토리 메서드를 통해 생성할 수 있도록 추가 + + * refactor: 불필요한 메서드에 대해 리팩토링 진행 + + * feat: 파일(이미지) 갱신 기능 구현 + + - FileId(PK)와 변경하고싶은 파일(MultipartFile)을 받아 기존에 존재했던 파일(이미지)는 삭제하고, 전달받은 파일로 갱신하는 기능 구현 + - 파일 갱신 테스트용 API 구현 + + * feat: 파일(이미지) 삭제 로직 추가 + + - 삭제하고자하는 Files 엔티티의 PK를 전달했을 때, 삭제하는 기능 구현 + - 저장소에 저장되어 있던 파일(이미지) 삭제 + - Files 엔티티 삭제 + +commit 89c31c56149fec830bd6b1bccdb7eb9b3873f684 +Author: DoHyung Kim +Date: Tue Jan 30 22:32:12 2024 +0900 + + [FEAT] 홈 화면 - 챌린지 검색 기능 개발 (#51) + + * chore: testBaseUtil 개발 중 패키지 구조 변경으로 인한 커밋 + + * feat: Home 검색 기능 개발 + + * refactor: search dto 네임 변경 + + * test: 단위 테스트 수행 + + - controller test 중 mongodb 이슈 발생 -> 해결요망 + - 검색 기능 postman test 완료 + + * test: 검색 기능 단위 테스트 + + * feat: 검색 조건에 따른 키워드 검색 기능 개발 + + - stringToEnum converter 재정의 + - 스프링 빈으로 등록하여 생성한 컨버터 사용 적용 + - 검색 조건에 따른 각 계층별 코드 작성 + + * refactor: 불필요한 코드 제거 + + * refactor: 코드 리펙토링 + +commit 27bd71465d8e6c43a4e64dcfb571ffd6a885f8a2 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Sat Jan 27 23:27:38 2024 +0900 + + [REFACTOR] 이미지/파일 요청과 응답 시 base64 인코딩하여 전달하도록 변경 (#49) + + * refactor: 파일과 관련된 응답 객체 전달 시, base64로 인코딩하여 전달 + + - 파일/이미지 저장 요청 & 파일/이미지 정보 요청에 대한 응답 시, base64로 인코딩하여 전달하도록 리팩토링 + + * refactor: base64로 인코딩한 값을 전달하도록 변경 + +commit 77f54caa2f77b9ce040738b2a2f642b03a84d1d8 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Jan 26 16:33:27 2024 +0900 + + chore: 패키지 구조 변경 + + - admin 패키지: 관리자 관련 기능과 밀접한 기능들 + - challenge 패키지: 사용자 관련 기능과 밀접한 기능들 + - global 패키지: file, security, util 같은 서비스 전체에 영향을 줄 수 있는 기능들 + +commit 8929e06e2cbb61717b00f76ae08af526d09a2fee +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Fri Jan 26 16:21:42 2024 +0900 + + [TEST] JWT, 회원가입 로직 테스트 코드 추가 (#47) + + * Squashed commit of the following: + + commit 025fd0c5faf4a2d6ab159f4b0a95c8c7cde751a3 + Author: SSung023 <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 23:15:25 2024 +0900 + + !HOTFIX: conflict resolve 해결 + + commit d2f1db695856489b13de47accb700c9432051ff3 + Author: HEY <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 23:14:14 2024 +0900 + + [FIX] JWT 재발급 관련 버그 픽스 (#45) + + * fix: access-token 재발급 안되는 버그 수정 + + * fix: 예외 처리 로직 추가 및 무한 리다이렉션 버그 픽스 + + - Cookie로부터 토큰을 얻을 때, cookie가 비어있을 때 예외 처리 로직 추가 + - JWT 토큰 요청 시, 사용자의 권한이 NOT_REGISTERED(가입 이전)이라면 JWT 토큰 발급 거부 로직 추가 + - refresh-token이 비어있는 경우 예외 처리 + + * chore: test 코드 정리 + + commit 42f6e36ae95189025342ee0730485d7c6e37cd20 + Author: SSung023 <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 21:43:28 2024 +0900 + + !HOTFIX: 회원가입 기능 핫픽스 + + - 회원가입 완료 후 반환하는 Response 객체의 구조 변경 + + commit f9a0e3320212a76c1fc33f291c4146b93d81963a + Author: HEY <50323157+SSung023@users.noreply.github.com> + Date: Thu Jan 25 18:09:04 2024 +0900 + + Update issue templates + + * test: JwtService에 대한 테스트 코드 추가 + + * test: JWT 테스트 코드 추가 + + * test: 회원가입 관련 테스트 코드 추가 + +commit 025fd0c5faf4a2d6ab159f4b0a95c8c7cde751a3 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 25 23:15:25 2024 +0900 + + !HOTFIX: conflict resolve 해결 + +commit d2f1db695856489b13de47accb700c9432051ff3 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 25 23:14:14 2024 +0900 + + [FIX] JWT 재발급 관련 버그 픽스 (#45) + + * fix: access-token 재발급 안되는 버그 수정 + + * fix: 예외 처리 로직 추가 및 무한 리다이렉션 버그 픽스 + + - Cookie로부터 토큰을 얻을 때, cookie가 비어있을 때 예외 처리 로직 추가 + - JWT 토큰 요청 시, 사용자의 권한이 NOT_REGISTERED(가입 이전)이라면 JWT 토큰 발급 거부 로직 추가 + - refresh-token이 비어있는 경우 예외 처리 + + * chore: test 코드 정리 + +commit 42f6e36ae95189025342ee0730485d7c6e37cd20 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 25 21:43:28 2024 +0900 + + !HOTFIX: 회원가입 기능 핫픽스 + + - 회원가입 완료 후 반환하는 Response 객체의 구조 변경 + +commit f9a0e3320212a76c1fc33f291c4146b93d81963a +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 25 18:09:04 2024 +0900 + + Update issue templates + +commit 8bbc369a49f124b039e6d8c8f483d82abc399053 +Merge: a120e65 caa3f58 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 24 21:05:09 2024 +0900 + + Merge pull request #39 from TeamTheGenius/feat/21-image-util + + [FEAT] 이미지/파일 업로드 유틸 클래스 개발 + +commit caa3f58041fa48216e8fa7fa98a57711a6fc00c0 +Merge: 32ca58a a120e65 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 24 21:04:10 2024 +0900 + + Merge branch 'main' into feat/21-image-util + +commit a120e651f47371ee7d809823a6e8fd3c1ecd01a6 +Author: DoHyung Kim +Date: Wed Jan 24 20:48:02 2024 +0900 + + 24 feat admin topic api (#41) + + * feat: Topic Controller 개발 + + * feat: Topic Domain 비즈니스 로직 개발 + + * feat: Topic Service 개발 + + * test: topic rest api 테스트 코드 작성 + + - jwt 관련 오류로 인해 테스트 불가 -> 해결 방안 모색 중 + + * feat: 전체 Topic 조회 페이징과 정렬 + + * feat: Instance API 개발 + + - DTO 활용 + - BusinessException 활용 + + * refactor: topic controller & service DTO 도입 + + * refactor: topic, instance paging 리팩토링 + + * refactor: 리펙토링 + + - participant_count entity 제거 -> participantInfo list size로 해결 가능 + - 테스트 코드 작성 + - 코드 리펙토링 등 + + * refactor: admin page refactoring + + - 연관관계 편의 메서드 수정 + - DTO 수정 + - API Response 재정의 + - util의 ErrorCode Enum 추가 + - PagingResponse 추가 + + * test: postman api test + + - test 수행 중 발견한 수정사항 해결 + + * test: test 완료 + +commit 32ca58aa9da64d644921bbdd2eb515214ab8cc7c +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 24 01:22:25 2024 +0900 + + feat: Files 엔티티 연관관계 설정 + +commit cf3b20ef842ac2d925e590fc1188639074e6f63f +Merge: a1014bc 061171e +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 24 00:39:39 2024 +0900 + + Merge branch 'main' into feat/21-image-util + +commit 061171e13bf48328f6f5bc94b3a9dc5899cab431 +Author: DoHyung Kim +Date: Wed Jan 24 00:37:56 2024 +0900 + + [FEAT] 어드민 페이지 개발 (#38) + + * [FEAT] Admin 페이지 Instance , Topic API 개발 (#33) + + * feat: Topic Controller 개발 + + * feat: Topic Domain 비즈니스 로직 개발 + + * feat: Topic Service 개발 + + * test: topic rest api 테스트 코드 작성 + + - jwt 관련 오류로 인해 테스트 불가 -> 해결 방안 모색 중 + + * feat: 전체 Topic 조회 페이징과 정렬 + + * feat: Instance API 개발 + + - DTO 활용 + - BusinessException 활용 + + * refactor: topic controller & service DTO 도입 + + * refactor: topic, instance paging 리팩토링 + + * refactor: 리펙토링 + + - participant_count entity 제거 -> participantInfo list size로 해결 가능 + - 테스트 코드 작성 + - 코드 리펙토링 등 + + * refactor: admin page refactoring + + - 연관관계 편의 메서드 수정 + - DTO 수정 + - API Response 재정의 + - util의 ErrorCode Enum 추가 + - PagingResponse 추가 + + * [REFACTOR] 어드민 페이지 리펙토링 (#36) + + * feat: Topic Controller 개발 + + * feat: Topic Domain 비즈니스 로직 개발 + + * feat: Topic Service 개발 + + * test: topic rest api 테스트 코드 작성 + + - jwt 관련 오류로 인해 테스트 불가 -> 해결 방안 모색 중 + + * feat: 전체 Topic 조회 페이징과 정렬 + + * feat: Instance API 개발 + + - DTO 활용 + - BusinessException 활용 + + * refactor: topic controller & service DTO 도입 + + * refactor: topic, instance paging 리팩토링 + + * refactor: 리펙토링 + + - participant_count entity 제거 -> participantInfo list size로 해결 가능 + - 테스트 코드 작성 + - 코드 리펙토링 등 + + * refactor: admin page refactoring + + - 연관관계 편의 메서드 수정 + - DTO 수정 + - API Response 재정의 + - util의 ErrorCode Enum 추가 + - PagingResponse 추가 + + * [FEAT] 어드민 페이지 postman API 테스트 (#37) + + * feat: Topic Controller 개발 + + * feat: Topic Domain 비즈니스 로직 개발 + + * feat: Topic Service 개발 + + * test: topic rest api 테스트 코드 작성 + + - jwt 관련 오류로 인해 테스트 불가 -> 해결 방안 모색 중 + + * feat: 전체 Topic 조회 페이징과 정렬 + + * feat: Instance API 개발 + + - DTO 활용 + - BusinessException 활용 + + * refactor: topic controller & service DTO 도입 + + * refactor: topic, instance paging 리팩토링 + + * refactor: 리펙토링 + + - participant_count entity 제거 -> participantInfo list size로 해결 가능 + - 테스트 코드 작성 + - 코드 리펙토링 등 + + * refactor: admin page refactoring + + - 연관관계 편의 메서드 수정 + - DTO 수정 + - API Response 재정의 + - util의 ErrorCode Enum 추가 + - PagingResponse 추가 + + * test: postman api test + + - test 수행 중 발견한 수정사항 해결 + + --------- + + Co-authored-by: HEY <50323157+SSung023@users.noreply.github.com> + +commit a1014bc447c985f10cf9014cf07623c1c4896d3c +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 21:30:13 2024 +0900 + + feat: 파일(이미지) 전송 기능 개발 + +commit 077a466c9fe52d1459f622eeb115d5460e8069d8 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 20:58:05 2024 +0900 + + feat: 이미지 업로드 API 추가 + +commit 8a235d40eb5364ad4992108f6d3225e9ce3b4394 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 19:35:26 2024 +0900 + + feat: 파일 업로드를 테스트하는 임시 API 추가 + +commit d04d40df208c56d70d7540b06a86060c1b7ae94c +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 19:34:52 2024 +0900 + + feat: 저장소, DB에 이미지를 저장하는 로직 개발 + +commit 4ca9006aeaf03af28867c5bb60db0b37e3abb368 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 18:33:56 2024 +0900 + + feat: 유효성 검사, 저장할 File 생성하는 FileUtil 클래스 구현 + + - 전달받은 파일의 유효성 검사, 저장할 File을 생성하는 로직을 FileUtil 클래스에 구현 + - FileUtil 관련 서비스 코드 작성 + - Files 클래스에 BaseTimeEntity 상속 + +commit c513a3ee39ac6b4aea10a3783e7fb4c813ad2694 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 16:06:12 2024 +0900 + + feat: 이미지/파일 레포지토리 생성 및 테스트 코드 추가 + +commit beccdca33dbbe36b9aa060817b72920f0b26b465 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 23 16:05:27 2024 +0900 + + feat: 이미지/파일을 저장할 엔티티 작성 + + - 이미지/파일을 저장할 엔티티 및 enum 클래스 작성 + +commit 6f17d9f078e024f323320cf65d19ebab1f9d73a9 +Merge: da4a86f 5b1c149 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 22 23:13:53 2024 +0900 + + Merge pull request #35 from TeamTheGenius/feat/34-auth-anotation + + [FEAT] Controller(API) 테스트 코드 작성에 필요한 유틸 개발 + +commit 5b1c149da4e82c4c81e873199a10611d23a13030 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 22 09:57:47 2024 +0900 + + feat: 테스트에 이용할 JWT 토큰 생성 유틸 클래스 구현 + +commit 70fda414ec1e8da9e68e703b28eb5c0d85d8fc5e +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 22 02:46:16 2024 +0900 + + feat: 커스텀 어노테이션 통해 인증 객체를 반환받는 기능 추가 + +commit da4a86f539863d3a99abffb721b36a2a185093eb +Author: kimdozzi +Date: Fri Jan 19 17:55:28 2024 +0900 + + refactor: 엔티티 리펙토링 + +commit da5bf8b7d93d19647c882a13c8ee2e2ec4c9deaa +Author: DoHyung Kim +Date: Fri Jan 19 15:01:39 2024 +0900 + + [REFACTOR] DB entity 리펙토링 (#32) + + * refactor: 소셜 로그인 facebook 관련 파일 제거 + + * feat: DB Entity 개발 + + - User Entity 수정 + - Hits, Topic, Instance, ParticipantInfo Entity 개발 + + * feat: challenge domain 및 repository 개발 + + - entity 수정 및 애노테이션 추가 + - DB Table 별 repository 추가 + - User entity : email -> identifier 로 변경 + + * refactor: 엔티티와 파일 리펙토링 + + - entity refactoring + - 프로젝트 구조 변경 + + * chore: P6Spy query logging 의존성 추가 + + * test: 기능별 도메인 생성 테스트 코드 추가 + + * test: User 회원 추가/조회/수정/삭제 테스트 코드 작성 완료 + + * feat: User와 Instance 다대다 연관관계 편의 메서드 작성 + + - 테스트 코드 작성 완료 + + * feat: User와 Instance 다대다 연관관계 편의 메서드 작성 - 2 + + - 관심목록, 인스턴스에 참여한 유저 정보 - 테스트 완료 + - 하나의 인스턴스가 가지는 Hits 테이블의 컬럼 갯수를 조회함으로서 Instance 테이블의 like_count 컬럼을 제거해도 됨. -> 리펙토링 예정 + + * refactor: 불필요한 entity 제거 + + * feat: 토픽과 인스턴스 연관관계 편의 메서드 작성 + + * test: 기존에 작성한 불필요한 코드 제거 및 테스트 + +commit 204b043da7ea421155219f71d07ce43b4a72a638 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 16 20:36:46 2024 +0900 + + chore: 프로젝트 이름을 GitGet로 변경 + +commit fd5ebec58e04486882552f8e79c37c635c9450af +Merge: c93b35e 8c0ff0f +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 16 20:20:28 2024 +0900 + + Merge pull request #29 from TeamTheGenius/feat/17-jwt + + [FEAT] 회원가입&JWT(로그인, 로그아웃) 기능 개발 + +commit 8c0ff0f3c4416ad7ec6e12d9a23c602a09319b6b +Merge: 0101d4a c93b35e +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 16 20:20:02 2024 +0900 + + Merge branch 'main' into feat/17-jwt + +commit 0101d4a87f3262feae0b0fa2518bd773702d8026 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 16 20:03:41 2024 +0900 + + test: JwtService 관련 테스트 코드 추가 + +commit c93b35e4a4c0156e05de48cb302d8d78072742ae +Author: DoHyung Kim +Date: Tue Jan 16 20:03:32 2024 +0900 + + [FEAT] DB Entity 개발 (#28) + + * refactor: 소셜 로그인 facebook 관련 파일 제거 + + * feat: DB Entity 개발 + + - User Entity 수정 + - Hits, Topic, Instance, ParticipantInfo Entity 개발 + + * feat: challenge domain 및 repository 개발 + + - entity 수정 및 애노테이션 추가 + - DB Table 별 repository 추가 + - User entity : email -> identifier 로 변경 + +commit ef0e5a065a9ad1e6ad782e3b6439d453cc3722c5 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 16 16:09:16 2024 +0900 + + feat: refresh token 탈취 감지 로직 구현 + + 1. 요청받은 Refresh-token과 DB에 저장된 토큰이 불일치 시 토큰 탈취됨을 감지하는 로직 구현 + 2. 토큰 탈취 감지 시, 강제 로그아웃 실행 + 3. 관련 테스트코드 추가 + +commit 061b7d472c85732abdb21e07446e989adbee7363 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 16 10:25:30 2024 +0900 + + Update pull_request_template.md + +commit 69f008dab7d216ee2ce8f9c6eedf4c676da242a2 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 15 14:30:10 2024 +0900 + + refactor: JWT 관련 코드 내 리팩토링 + + 1. 변수명 적절하게 수정 + 2. TokenStatus enum 도입 및 적용 + 3. 메서드 추출 + +commit 48eb1aca2ee19c2aec82bdf56560357bcad07dce +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 15 13:01:41 2024 +0900 + + feat: 회원가입 시 닉네임 중복 확인 API 구현 + +commit cfba09dbb126867bf49471dfcebda89d8d027dd0 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 15 02:31:15 2024 +0900 + + feat: logout API 구현 + +commit a3f87848bee19b15caf3dfe040c453137fcc6b66 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 15 01:46:08 2024 +0900 + + refactor: JwtUtil 클래스 분리를 통한 리팩터링 + +commit 64094f3848cc380f03370e0483d699b1dde88b55 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 23:39:51 2024 +0900 + + feat: JWT 검증 필터 로직 및 재발급 로직 구현 + + - RTR 로직 구현 + - 전반적인 리팩토링 필요 + +commit f96fd8b8e7e05149611e5a32b7be9bfbaaada32a +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 19:47:24 2024 +0900 + + refactor: JWT에 사용될 enum 클래스 선언 및 적용 + +commit 4ad0ea05a841b901864ef20ab1e03db9c9f20028 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 16:13:45 2024 +0900 + + feat: refresh-token 저장하는 로직 구현 + + - MongoDB에 refresh token을 저장하는 로직 구현 + +commit b684f925c84e38bd50cc33bfd59d575badfc9704 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 15:19:22 2024 +0900 + + feat: Token 정보를 담을 클래스/리포지토리 설정 + +commit bd259b4fd9e5d439c9e383575e549723bbaa4d42 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 15:18:08 2024 +0900 + + feat: MongoDB 의존성 추가 + +commit 921e387c72c436980afe372ef4dbdc924aadf839 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 14:37:28 2024 +0900 + + refactor: JWT 요청 시, 전달하는 사용자 정보 변경 + + - 사용자의 PK를 보내는 방법에서 Identifier 정보 전달로 변경 + +commit bb2852482ff076106c27187a3dd6d1a006e21df8 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Jan 14 14:30:32 2024 +0900 + + chore: mongoDB 의존성 추가 + +commit 9398af7f9304eff4587fa099d140cead98652580 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Jan 12 10:54:37 2024 +0900 + + fix: 회원가입 요청 DTO 버그 수정 + +commit ebec72d9cae6dc93c79bbf3afdfcff94c084fc3a +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Jan 12 09:56:35 2024 +0900 + + fix: OAuthRule enum 클래스 수정으로 인한 버그 수정 + +commit 1f6dddf88be7aab31b948da4da939b72f70e0cf9 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 11 11:48:33 2024 +0900 + + refactor: OAuth2와 관련된 상수를 하나의 enum 클래스에 모음 + +commit f42e400b1e49713dc062f6df8682ffe188f36447 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 11 01:25:55 2024 +0900 + + refactor: filterChain을 Security 6에 맞게 람다로 변경 + +commit 8be9964397d99e5054019c5068074248d11d83ed +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 11 01:11:58 2024 +0900 + + refactor: CorsConfigurationSource 클래스 분리 + +commit 2b93f74ed713bb4142925d1da07b817fcb49186f +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Thu Jan 11 00:53:31 2024 +0900 + + feat: JWT 발급 API 요청 시, access & refresh token 발급 로직 개발 + + - JwtAuthorizationFilter 세부 구현 필요 + - JwtService 내의 매직 넘버 처리 필요 + +commit edb1c2f2048014ab5cda2bb9c690140bdafe0216 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 9 14:40:43 2024 +0900 + + feat: Github 소셜로그인 추가 및 구조 변경 + + - Github 소셜로그인 추가 + - Github 소셜로그인 추가에 따라, Spring security에서 Authentication을 찾는데에 사용하는 값 변경 + +commit 6de4dbfc9dbd764729afea474ddd659636e5d955 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 3 14:50:44 2024 +0900 + + feat: refresh token 생성 로직 구현 + +commit d71bfe4970ac3df816e4b4774edee3c87095f588 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 3 12:19:15 2024 +0900 + + chore: 코드 최적화 + +commit 9dfc46d82297b3bbfc9c57098b57b1f7d0d81d03 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Wed Jan 3 12:17:29 2024 +0900 + + feat: Security Context에 사용자의 정보를 담는 클래스 구현 + + - UserPrincipal가 DefaultOAuth2User 상속 -> OAuth2User 인터페이스 상속으로 변경 + - UserPrincipal 클래스에 UserDetails 인터페이스 상속 + - 인터페이스 상속으로 인한 메서드 구현 + - OAuth2User의 getName(): email 반환 + - UserDetails의 getUsername(): User PK를 String 형으로 반환 + +commit 7f1fdd0d98afbd9c87845532fc4e1e766af3d5d2 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 2 11:55:45 2024 +0900 + + chore: jwt 관련 의존성 추가 + +commit b7848d77953aa7c7549cf35c9a09e54a9955cb4e +Merge: f03d1f7 61d873c +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 2 10:16:42 2024 +0900 + + Merge pull request #16 from TeamTheGenius/feat/social-login + + Feat: 회원가입 API 개발 + +commit f03d1f7742cc4dd13a756903a1446188752b90b2 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 2 01:06:37 2024 +0900 + + Update issue templates + +commit 61d873c68b7441a487732328f2e34a7c778c9fd6 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Tue Jan 2 00:57:13 2024 +0900 + + refactor: 공통 응답 객체에 값 추가 + +commit 6cea0ac9e1170ca19ea6277ddc78a22f8cd392a6 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 1 14:15:19 2024 +0900 + + refactor: BusinessExceptionHandler의 반환타입 변경 + +commit 09330b408572662244ddfe08b75661e9d42cd470 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 1 13:45:06 2024 +0900 + + chore: final 상수 이름을 네이밍 컨벤션에 맞게 변경 + +commit 76b6c8f5aec2090e1b54b3512bfd1cfdf8c107ac +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 1 02:22:58 2024 +0900 + + feat: 회원가입 로직 개발 + + - 소셜로그인 성공 이후 회원가입 이전 사용자를 대상으로, 회원가입을 진행하는 로직 개발 + - FE로부터 배열 형태로 받은 관심사를 처리하는 Converter 클래스 구현 필요 + +commit 8f0137fd9f749d09eeba6e9cb632240a7267b250 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Mon Jan 1 01:51:23 2024 +0900 + + chore: build.gradle에서 lombok 의존성 추가 + + - test 코드에서도 사용할 수 있도록 의존성 추가 + +commit 748cda29f95d3557139fe694309856462f8d3e78 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 17:17:55 2023 +0900 + + chore: 패키지명 변경 + +commit d074cc947e66bcefe7b671b6ac7e9ea4ebe86d69 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 15:54:08 2023 +0900 + + chore: 어플리케이션 baseURL 수정 + +commit 17db90d6c66f971665979df363e13d4861ffb84c +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 14:56:02 2023 +0900 + + chore: swagger 관련 URI 수정 + +commit baf94401d73f809e5b1f7c6d352aabb8b7b49f79 +Merge: 33c4234 3cb3358 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 14:37:45 2023 +0900 + + Merge pull request #15 from TeamTheGenius/feat/7-add-utils + + Feat: Utils 파일 추가 + +commit 3cb335899010df60b07385d26d51c4a0b2b04b04 +Merge: af5971f 33c4234 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 14:37:23 2023 +0900 + + Merge branch 'feat/social-login' into feat/7-add-utils + +commit af5971f3cd09e23180c41eaeeaa9a8af3c48350e +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 14:09:03 2023 +0900 + + feat: swagger 설정 파일 추가 + +commit 33c42342a038ac0b613ccfa79a79770ee5522b6b +Merge: c24f5bd ea5d3b2 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 12:39:58 2023 +0900 + + Merge pull request #14 from TeamTheGenius/feat/social-login + + Feat: 소셜로그인에 필요한 DTO 개발, 클래스 커스터마이징 및 로직 구현 + +commit ea5d3b225e0ac9a5f0b18acdd5ad2b4ec40f50a1 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 12:37:03 2023 +0900 + + chore: 사용하지 않는 클래스 삭제 및 magic number 상수로 변환 + +commit a103100304b1ba94c7094e40e86d29c7f6bdb398 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 12:05:54 2023 +0900 + + fix: 인증 클래스 생성자에 사용자의 역할을 전달하도록 수정 + + - 기존에 무조건 ROLE_USER를 전달하던 로직에서, 생성자에 사용자의 역할을 전달하도록 수정 + +commit ec2a0acf3248181747e9b04dc952a1c043c3ce65 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 03:26:46 2023 +0900 + + refactor: 인증 객체 상속관계 변경 + + - 기존에 OAuth2User 인터페이스 상속에서 DefaultOAuth2User 상속으로 변경 + - getName() 메서드 오버라이드: 사용자의 이메일을 반환하도록 설정 + +commit 25ca958b019ba9c96fb8234b19dccf77f5f05aba +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 03:17:50 2023 +0900 + + refactor: 소셜 별 UserInfo 클래스 리팩토링 진행 + + - 매직 넘버(magic number)에 대해 상수로 변경 + - 인증 객체에 필요한 메서드 추가 + +commit 20580d9478afba19c63391a905f6dc334482099c +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 02:05:50 2023 +0900 + + feat: 소셜로그인 관련 Handler 로직 구현 + + - 소셜로그인을 통한 인증 성공 후, 실행되는 OAuth2SuccessHandler 클래스 로직 구현 + - 소셜로그인 도중 모종의 이유로 인해 실패했을 경우, 실행되는 OAuth2FailureHandler 클래스 로직 구현 + +commit 52dbed24f9c1c4f837ada471d423c1c23f0d82e1 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sun Dec 31 01:34:24 2023 +0900 + + feat: 소셜로그인 성공 시 실행되는 서비스 클래스 개발 + + - 서드파티로부터 access-token 받은 이후 실행되는 custom 서비스 클래스 로직 구현 + - 사용자 정보(email)을 받아온 후 -> DB에 저장 여부 확인 -> 없다면 DB에 저장 + +commit 431013464b7112a4ed472982f39c4f3c0d532def +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sat Dec 30 23:06:06 2023 +0900 + + refactor: 소셜로그인 관련 파일들 구조 변경 + +commit bd1572b8eabee22e5cff10bb31f5a0c76d21bb89 +Merge: 7e6f06a 3d17f1e +Author: kimdozzi +Date: Sat Dec 30 17:06:46 2023 +0900 + + Merge remote-tracking branch 'upstream/feat/social-login' into feat/social-login + +commit 7e6f06a8007ff56881ecea6ec58eeccdd8f17342 +Author: kimdozzi +Date: Sat Dec 30 16:58:37 2023 +0900 + + Feat: 폴더 구조 변경 + +commit 368c804680dac110069481cd4f7b56a17efcd857 +Author: kimdozzi +Date: Sat Dec 30 16:58:09 2023 +0900 + + Feat: 소셜로그인 테스트를 위한 의존성 추가 및 수정 + +commit db87b1a7287d7bf9535b80d59062214603e62285 +Author: kimdozzi +Date: Sat Dec 30 16:54:32 2023 +0900 + + Feat: 소셜별 UserInfo DTO 개발 + +commit 5005b951fa755cc17b3c4fe1165a861027d2cd6b +Author: kimdozzi +Date: Sat Dec 30 16:53:24 2023 +0900 + + Feat: 소셜로그인에 필요한 클래스 커스터마이징 및 개발 + +commit 6c828bbe32b96749e2da988304f5a6b6e97465d4 +Author: kimdozzi +Date: Sat Dec 30 16:51:44 2023 +0900 + + Feat: Config 수정 및 entity 수정 + +commit 264c5e2b37ecb781fdcd2714a6560f114ac7bfe2 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sat Dec 30 02:19:56 2023 +0900 + + feat: CORS 처리 필터 추가 + +commit 27f54dbc88c2b8b23c9326e9a381b8ec05c087b3 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sat Dec 30 02:16:50 2023 +0900 + + feat: Time formatting 파일 추가 + +commit 7534ca1d2e3f2b1309058832c58e9fd8bb14b542 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sat Dec 30 02:16:25 2023 +0900 + + feat: Response 형식에 따른 타입 통일 + + - 반환하는 값의 형식에 따른 Response DTO 타입 지정 + +commit c3fe05f2ffe9888fce0d9d9d93ecf1b8b9c56c55 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Sat Dec 30 02:14:48 2023 +0900 + + feat: Exception 클래스 및 ExceptionHandler 클래스 생성 + + - BusinessException: 비지니스 로직에서 발생한 예외를 처리할 때 해당 예외 클래스를 throw + - BusinessExceptionHandler : 사용자가 처리한 예외(BusinessException)을 어떻게 처리할 것인지 구현 + - GlobalExceptionHandler: 개발자가 처리하지 않은 예외가 던져졌을 때 처리되는 부분 -> 추가 처리 필요함을 날리는 곳 + - ErrorCode: 예외 메시지를 저장하는 Enum 클래스 + +commit 3d17f1e8b3c619c49612e6f14489ecb7dc200a35 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 23:59:03 2023 +0900 + + hotfix: security 패키지 위치 변경 + +commit 86c029e7edddf644018f6b7afc5ebcb7b568a688 +Merge: 31a7b79 c24f5bd +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 23:44:15 2023 +0900 + + Merge branch 'main' into feat/social-login + +commit 31a7b790c06827303da27af54df70cf141dee8be +Merge: 8ae3e86 8ec6058 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 13:35:45 2023 +0900 + + Merge pull request #12 from TeamTheGenius/setting + + Feat: Spring Security, OAuth 2.0에서 필요한 설정 진행 + +commit c24f5bddf9f746e98c2cb88c5468806dbcee984c +Merge: 8ae3e86 29e6938 +Author: HEY <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 13:35:35 2023 +0900 + + Merge pull request #13 from TeamTheGenius/common + + Feat: 소셜로그인에 필요한 User Entity 및 Repository 개발 + +commit 29e6938f3642fe13d9884d1b175bfc08023a870b +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 02:21:47 2023 +0900 + + feat: 사용자를 이메일, provider를 통해 찾는 기능 개발 + + - JpaRepository 인터페이스를 상속하여 구현 + - 이메일 / 이메일+provider 를 통해 DB에서 사용자를 찾는 기능 구현 + - 두 메서드에 대해 단위 테스트 코드 작성 + +commit 6bf4468a966d026e7b56c54a2fca0a142ea534eb +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 02:20:07 2023 +0900 + + feat: User Entity 추가 + + - JPA를 활용하여 User 엔티티 클래스 작성 + - BaseTimeEntity 상속 + - 사용자의 회원가입 여부/ 권한에 대한 정보를 담고 있는 Enum class 작성 + +commit 844cedddb9ea95f5cffc7073e62777cb2e9da227 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 02:10:30 2023 +0900 + + feat: 공통 필드를 가지고 있는 Entity 추가 + + - 생성시간, 수정시간, 삭제시간 필드를 가지고 있는 공통 Entity 추가 + - createdDate, modifiedDate 자동 기록을 위해 + @EnableJpaAuditing 어노테이션 추가 + +commit 8ec6058776a0ed0beaa87062b3d9471c1246b7f3 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 01:24:55 2023 +0900 + + feat: 서비스에 적용되는 Provider Enum 클래스 생성 + + - 정적 팩토리 메서드를 통해 provider에 맞는 AuthProvider를 반환 + +commit 9f887ff18e8e5642c268a60143700eeeb3ca6988 +Author: SSung023 <50323157+SSung023@users.noreply.github.com> +Date: Fri Dec 29 01:23:23 2023 +0900 + + feat: SecurityConfig에서 소셜로그인에 필요한 설정 추가 + + - OAuth2.0을 이용한 소셜로그인에 필요한 설정 추가 (filterChain을 통해 구현) + - 실제 객체가 생성되어있지 않아 작성할 수 없는 부분은 주석 처리 (추후 주석 해제 필요) + - application-oauth.yml 파일에 소셜로그인에 대한 설정 후, 테스트 시 정상작동 확인 + +commit 8ae3e86e7916db6951020743d1ed28c54430cbfa +Author: DoHyung Kim +Date: Mon Dec 25 21:23:18 2023 +0900 + + Update issue templates + +commit 4ca2ccf2d8bdb7d767987cbd53969fae228f3a40 +Author: DoHyung Kim +Date: Mon Dec 25 21:21:38 2023 +0900 + + Update issue templates + +commit ed9ef2799075a0ee06beeac6130e1cfd607899cd +Author: DoHyung Kim +Date: Mon Dec 25 21:15:11 2023 +0900 + + Create pull_request_template.md + +commit 03de9128b78939781f1ba83d4b31b33dcae96474 (origin/production, origin/pre-production) +Author: SSung023 +Date: Mon Dec 25 20:48:00 2023 +0900 + + init: 프로젝트 초기 세팅 + + - .yml 파일에 DB 추가 세팅 필요 + +commit e1c602ff455e6d6cf6eca31f69bf2b03311cc90a +Author: SSung023 +Date: Mon Dec 25 20:42:34 2023 +0900 + + init: 초기 세팅 revert + +commit f033f9991c5722d2ba7f2fd2c65704c243e3e87c +Author: SSung023 +Date: Mon Dec 25 20:41:23 2023 +0900 + + Revert "init: 프로젝트 초기 세팅" + + This reverts commit ac57c42d0c3241a46bf24c6757518d26b8f822e7. + +commit ac57c42d0c3241a46bf24c6757518d26b8f822e7 +Author: SSung023 +Date: Mon Dec 25 20:40:08 2023 +0900 + + init: 프로젝트 초기 세팅 + + - .yml 파일에 DB 추가 세팅 필요 diff --git a/todoffin/build.gradle b/todoffin/build.gradle deleted file mode 100644 index 846cd36c..00000000 --- a/todoffin/build.gradle +++ /dev/null @@ -1,41 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.2.1' - id 'io.spring.dependency-management' version '1.1.4' -} - -group = 'com.genius' -version = '0.0.1-SNAPSHOT' - -java { - sourceCompatibility = '17' -} - -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-websocket' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' -} - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/todoffin/settings.gradle b/todoffin/settings.gradle deleted file mode 100644 index c39c0ad4..00000000 --- a/todoffin/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'todoffin' diff --git a/todoffin/src/main/java/com/genius/todoffin/TodoffinApplication.java b/todoffin/src/main/java/com/genius/todoffin/TodoffinApplication.java deleted file mode 100644 index 239cbcda..00000000 --- a/todoffin/src/main/java/com/genius/todoffin/TodoffinApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.genius.todoffin; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class TodoffinApplication { - - public static void main(String[] args) { - SpringApplication.run(TodoffinApplication.class, args); - } - -} diff --git a/todoffin/src/test/java/com/genius/todoffin/TodoffinApplicationTests.java b/todoffin/src/test/java/com/genius/todoffin/TodoffinApplicationTests.java deleted file mode 100644 index 0774dfab..00000000 --- a/todoffin/src/test/java/com/genius/todoffin/TodoffinApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.genius.todoffin; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TodoffinApplicationTests { - - @Test - void contextLoads() { - } - -}