diff --git a/.env.example b/.env.example index 1e411c4a..7708dc7d 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,4 @@ MYSQL_USERNAME="bungae" MYSQL_PASSWORD="0000" GOOGLE_MAP_API_KEY="API_KEY" SSL_KEY_PASSWORD="bungae" +FLASK_MAIL_SERVER="http://localhost:5000" \ No newline at end of file diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml new file mode 100644 index 00000000..a01ad909 --- /dev/null +++ b/.github/workflows/build_test.yml @@ -0,0 +1,23 @@ +name: Java CI with Gradle + +on: + pull_request: + branches: + - 'master' + - 'develop' + - 'weekly/**' + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..c3274e7c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,31 @@ +name: Java CD on AWS EC2 with SSH Connection + +on: + push: + branches: + - 'master' + - 'develop' + - 'weekly/**' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands + uses: appleboy/ssh-action@v0.1.4 + with: + host: ${{ secrets.REMOTE_IP }} + username: ${{ secrets.REMOTE_USER }} + password: ${{ secrets.SSH_PASSWORD }} + port: 22 + script: | + cd Team3_BE + git pull + ./gradlew clean build || exit 1 # 빌드 실패 시 스크립트 종료 + + # 환경변수 설정 + set -a + source .env + set +a + + ./nohup.sh # 기존 서버 종료 후 백그라운드로 서버 실행 \ No newline at end of file diff --git a/.gitignore b/.gitignore index dc4e419a..14fe841d 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,4 @@ open-api-3.0.1.json goorm.manifest +aws/ diff --git a/Dockerfile b/Dockerfile index 0a32e852..1e1d20a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,12 @@ WORKDIR project # Spring 소스 코드를 이미지에 복사 COPY . . +# DATABASE_URL을 환경 변수로 삽입 +ENV DATABASE_URL=jdbc:mysql://mysql/bungaebowling_db + +# API URL 삽입 +ENV API_SERVER_URL=https://ka02fa9a0d9a2a.user-app.krampoline.com + # gradle 빌드 시 proxy 설정을 gradle.properties에 추가 RUN echo "systemProp.http.proxyHost=krmp-proxy.9rum.cc\nsystemProp.http.proxyPort=3128\nsystemProp.https.proxyHost=krmp-proxy.9rum.cc\nsystemProp.https.proxyPort=3128" > /root/.gradle/gradle.properties @@ -25,9 +31,6 @@ RUN ./gradlew clean build FROM builder AS final COPY --from=builder /home/gradle/project/build/libs/server-0.0.1.jar . -# DATABASE_URL을 환경 변수로 삽입 -ENV DATABASE_URL=jdbc:mysql://mysql/bungaebowling_db - # yml 선택 ENV PROFILE deploy diff --git a/README.md b/README.md index 853e803e..ac4836d5 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,393 @@ # Team3_BE -

+

Logo +

-볼링 모집 커뮤니티 번개볼링(임시)의 백엔드 서버입니다. +

번개 지향 볼링 모집 커뮤니티 "번개볼링"의 백엔드 서버입니다.

+ +>

카카오 테크 캠퍼스 1기 부산대 3조 프로젝트입니다.

+ +## Collaborators + +

Backend

+ +
+ +| 조장 | 테크 리더 | 기획 리더 | 리액셔너 | +| :----------------------------------------------------: | :-----------------------------------------------------: | :------------------------------------------------------: | :---------------------------------------------------: | +| [박소현](https://github.com/sososo0) | [안혜준](https://github.com/jagaldol) | [김기해](https://github.com/xcelxlorx) | [김윤재](https://github.com/yunzae) | +| | | | | + +
+ +

Frontend

+ +
+ +| 리마인더 | 타임 키퍼 | +| :------------------------------------------------------: | :---------------------------------------------------------: | +| [강주호](https://github.com/kjh302903) | [허동혁](https://github.com/Heo-Donghyuk) | +| | | + +
+ +## Introduction + +`기존의 볼링 관련 서비스`에서는 `볼링 한판`을 치기 위해서 동호회, 소모임에 가입을 해야하는 `번거로운 과정`이 필요합니다. 소모임, 밴드 앱 또한 일회성의 가벼운 만남이 아닌 주기적으로 참여를 할 회원을 모집하고 있습니다. + +**`번개 볼링`은 기존의 소모임, 스포츠 모임 서비스와 다른 번개모임, `빠른 매칭`을 목표로 하고 있습니다.** + +--- + +
+

기획

+ +#### 5Whys + + + +#### 1Pager 기획 + + + +#### Figma + +- [FigJam 기획](https://www.figma.com/file/x1qngNGszfTY3an4nDM2xN/3%EC%A1%B0?type=whiteboard&node-id=0%3A1&t=gwhdVZW6eLrRPOrp-1) +- [Figma Wireframe 서비스 디자인](https://www.figma.com/file/hKOS0wj6goXDFGyBRREknv/3%EC%A1%B0_%EC%99%80%EC%9D%B4%EC%96%B4-%ED%94%84%EB%A0%88%EC%9E%84?type=design&node-id=217%3A196&mode=design&t=CYh2mBqkgmHLu0aI-1) + +##
+ +### 둘러보기 + +- **[실제 배포 링크](https://ka02fa9a0d9a2a.user-app.krampoline.com/)** +- **[api문서](https://bungae.jagaldol.dev:8080/api/docs/swagger)** + +### 깃헙 레포지토리 + +- **[FrontEnd Repository](https://github.com/Step3-kakao-tech-campus/Team3_FE)** +- **[BackEnd Repository](https://github.com/Step3-kakao-tech-campus/Team3_BE)** + +## System Structure -> 카카오 테크 캠퍼스 1기 부산대 3조 프로젝트입니다. +### 전체 구성도 + +Logo + +### 백엔드 구성도 + +Logo + +### ERD(ER - Diagram) - [ERD 협업 링크](https://www.erdcloud.com/d/GHYAMbQS9pzC6k8ZB) + +Logo + +- 참여신청 테이블(applicant_tb) + + ```text + 승인 상태가 false 인 경우 프론트에서 수락 / 거절 처리합니다. + 거절 시 참여 신청 테이블에서 delete 됩니다. + 수락 시 status가 true가 되면서 수락 / 거절 처리됩니다. + 게시글이 모집완료되면 평가하기 활성화됨 status가 True인 사람들은 게시글에 달려있는 status True인 사람들을 서로 평가할 수 있습니다. + ``` + +- 모집글 테이블(comment_tb) + + ```text + 마감 계산은 아래와 같습니다. + 마감 = is_close || (now due_time) + ``` + +- 댓글 테이블(comment_tb) + + ```text + 일반 댓글의 경우 부모 댓글id가 NULL입니다. + 대댓글 일 시 부모 댓글id가 존재합니다. + 게시글에 달린 댓글을 전체 조회해서 부모 id에 맞게 조합하여 계층형으로 전달 가능합니다. + 댓글 데이터 삭제 시, delete하지 않고 작성자 id와 내용만 null 처리합니다. + (부모id를 참조해야하므로 row를 삭제해서는 안됩니다.) + ``` + +## Tech Stack + +
+ +![java 17](https://img.shields.io/badge/-Java%2017-ED8B00?style=for-the-badge&logo=java&logoColor=white) +![spring boot 3.1.3](https://img.shields.io/badge/Spring%20boot%203.1.3-6DB33F?style=for-the-badge&logo=springboot&logoColor=white) +![Python 3.8.10](https://img.shields.io/badge/python%203.8.10-3776AB?style=for-the-badge&logo=python&logoColor=white) +![Flask 2.2.2](https://img.shields.io/badge/Flask%202.2.2-000000?style=for-the-badge&logo=flask&logoColor=white) + +![mysql 8.0](https://img.shields.io/badge/MySQL%208.0-005C84?style=for-the-badge&logo=mysql&logoColor=white) +![Redis 6.2](https://img.shields.io/badge/Redis%206.2-DC382D?style=for-the-badge&logo=Redis&logoColor=white) +![AWS S3](https://img.shields.io/badge/AWS%20S3-569A31?style=for-the-badge&logo=amazons3&logoColor=white) +![AWS EC2](https://img.shields.io/badge/AWS%20EC2-FF9900?style=for-the-badge&logo=amazonec2&logoColor=white) +![Naver cloud](https://img.shields.io/badge/naver%20cloud-03C75A?style=for-the-badge&logo=naver&logoColor=white) + +![nginx 1.18.0](https://img.shields.io/badge/nginx%201.18.0-009639?style=for-the-badge&logo=nginx&logoColor=white) +![docker 24.0.7](https://img.shields.io/badge/docker%2024.0.7-2496ED?style=for-the-badge&logo=docker&logoColor=white) +![Kubernates 1.28.0](https://img.shields.io/badge/KUBERNETES%201.28.0-326CE5?style=for-the-badge&logo=Kubernetes&logoColor=white) +![github action](https://img.shields.io/badge/GITHUB%20ACTIONS-2088FF?style=for-the-badge&logo=github-actions&logoColor=white) + +
## How to Start -추가 예정 +1. 프로젝트를 클론합니다. -## Features + ``` + $ git clone https://github.com/Step3-kakao-tech-campus/Team3_BE.git + ``` -추가 예정 +2. `Temp3_BE/.env.example` 파일을 `.env`로 복사하고 내용을 자신의 환경에 맞게 설정해줍니다. -## Collaborators + ``` + $ cd Team10_BE # 디렉토리 이동 + $ cp .env.example .env # 파일 복사 + $ vi .env # .env 수정 + 파일 수정 및 저장 진행하기 + ``` -카카오 테크 캠퍼스 1기 부산대 3조 + - `.env` 파일은 환경변수를 설정하는 파일입니다. -| 조장 | 테크 리더 | 기획 리더 | 리액셔너 | -|:------------------------------------------------------:|:-------------------------------------------------------:|:--------------------------------------------------------:|:-----------------------------------------------------:| -| [박소현](https://github.com/sososo0) | [안혜준](https://github.com/jagaldol) | [김기해](https://github.com/xcelxlorx) | [김윤재](https://github.com/yunzae) | -| | | | | + ``` + TOKEN_SECRET="example" # 로그인시 사용되는 토큰의 시크릿 키를 설정 + DOMAIN="http://localhost:3000" # 배포될 도메인을 설정 + API_SERVER_URL="http://localhost:8080" # 서버URL 설정 + GMAIL_USERNAME="example@gmail.com" # 메일인증 등 메일발신에 사용될 GMAIL설정 + GMAIL_APPLICATION_PASSWORD="example" # 메일인증 등 메일발신에 사용될 GMAIL설정 + AWS_ACCESS_KEY="example" # AWS S3에 접근하기 위한 키 설정 + AWS_SECRET_KEY="example+9Vkr3fRwA" # AWS S3에 접근하기 위한 키 설정 + MYSQL_USERNAME="example" # 데이터 베이스 연결 설정 + MYSQL_PASSWORD="example" # 데이터 베이스 연결 설정 + GOOGLE_MAP_API_KEY="example" # 구글맵 API 키 설정 + SSL_KEY_PASSWORD="example" # SSL 키 설정 + FLASK_MAIL_SERVER="http://localhost:5000" # 구동 시킨 Flask SMTP 서버의 주소 + ``` + + - local profile을 사용할 시, `SSL`과 `MySQL`, `Flask` 설정이 불필요합니다. + +3. `.env` 환경변수를 등록합니다. (본 가이드에선 우분투 환경으로 진행합니다.) + + ``` + $ set -a + $ source .env + $ set +a + ``` + +4. `docker-compose.yml` 파일을 이용해 `Redis` 및 `MySQL` 도커 컨테이너를 실행합니다.(도커가 설치되어 있다고 가정합니다.) + + ``` + $ docker-compose up + ``` + +5. java파일을 빌드, 실행합니다. + + ```sh + $ ./gradlew clean build + $ java -jar build/libs/server-0.0.1.jar + ``` + + - 실제 배포를 위한 `product` 환경일 시, `spring.profiles.active` 설정을 추가하여 실행합니다. + + ```sh + $ ./gradlew clean build + $ java -jar -Dspring.profiles.active=product build/libs/server-0.0.1.jar + ``` + +--- + +### Flask SMTP 서버 실행 + +spring boot의 deploy profile을 사용하는 경우만 Flask SMTP 서버 실행이 필요합니다. + +> 카카오 크램폴린 상의 배포에서 SMTP가 지원되지 않습니다. 따라서 별개의 네이버 클라우드 상에서 SMTP 서버를 구축하였습니다. +> +> 그 외의 profile은 spring boot 내에서 자체적으로 SMTP를 사용하여 메일을 전송합니다. +1. 프로젝트를 클론합니다. + + ``` + $ git clone https://github.com/Step3-kakao-tech-campus/Team3_BE.git + ``` + +2. 폴더를 이동합니다. + + ``` + $ cd Team3_BE/flask + ``` + +3. Flask SMTP 서버를 실행합니다. + + ``` + $ ./start.sh + ``` + +4. 배포된 주소를 .env의 FLASK_MAIL_SERVER에 넣습니다. + +## FEATURES + +개발한 API들의 핵심 특성을 서술합니다. + +> 각 api들의 상세한 명세는 [Swagger 문서](https://ka02fa9a0d9a2a.user-app.krampoline.com/api/docs/swagger)를 확인 부탁드립니다. + +### 인증 + +JWT를 이용하였습니다. + +- ACCESS TOKEN과 REFRESH TOKEN을 구현하였으며 REFRESH TOKEN은 REDIS에 저장됩니다. +- RTR(refresh token rotation) 전략으로 리프레시를 일회용으로 사용하였습니다. +- ACCESS 토큰은 AUTHORIZATION 헤더로 BEARER 토큰으로 전달합니다. +- REFRESH 토큰은 HTTP-ONLY 쿠키로 전달하여 클라이언트의 접근을 막아 보안성을 강화하였습니다. +- 회원가입 시, 유저 권한을 바로 부여하지 않고 이메일인증을 필요하게 하여 보안성, 안정성을 강화하였습니다. + +**회원가입을 하지 않은 유저는 아래의 기능만 사용이 가능합니다.(주로 조회만 가능)** + + - 행정구역 관련 기능 + - 모집글 조회 + - 댓글 조회 + - 사용자 조회 (유저 기록 조회, 참여 기록 조회, 점수 조회) + +**회원가입을 한 유저는 아래의 기능을 추가로 사용할 수 있습니다.** + + - 자신의 회원정보 조회 및 수정 + - 쪽지 조회 + +**회원 가입 후 이메일인증을 받은 유저는 아래의 기능을 포함하여 모든 기능을 사용할 수 있습니다.** + + - 모집글 관련 모든 기능 + - 신청 관련 모든 기능 + - 댓글 관련 모든 기능 + - 쪽지 관련 모든 기능 + - 별점 등록, 점수 등록 기능 + +### 메일 전송 + +현재 SMTP 서버는 분리되어, Naver Cloud 상에서 배포 중입니다. + +#### Flask 사용 이유 + +크램폴린 환경에서는 카카오 정책으로 인해 HTTP 통신만 가능하다는 프로토콜 통신 제한이 있어, 외부에 SMTP 서버를 구축하여 SMTP 이메일 전송을 구현하기로 하였습니다. POST 요청에 의한 이메일 발송만 구현하면 되었기에 간결하면서도 필요한 기능을 구현할 수 있는 도구를 선택하려고 하였고, 이에 간결하게 사용할 수 있는 웹 Framework인 Flask로 메일 전송 요청에 따른 메일 전송 기능을 구현하게 되었습니다. + +#### SMTP 서버 구조 설명 + +크램폴린 환경에서만 Naver Cloud 환경에서의 flask 서버를 활용합니다. + +**HTTP POST 요청 생성하기** + +- SpringBoot 상에서 Flask로 보내게 될 HTTP 요청에 대한 request를 생성하기 위해 MultiValueMap을 이용하여 request body를 생성합니다. +- HTTP Header와 requestURL을 설정하고, Proxy 설정이 된 RestTemplate을 이용하여 HTTP 통신을 통해 Flask 서버로 POST 요청을 전송합니다. + +**SMTP 요청 생성하기** + +- HTTP POST 요청이 들어오면, Flask 서버는 SMTP 서버로 보내게 될 SMTP 요청을 생성합니다. +- request body에서 필요한 정보를 추출하고, SMTP 요청을 생성합니다. 이때, SMTP의 text 부분에 들어갈 내용이 html이므로, MIME Type을 text/html으로 설정해야 요청이 정상적으로 보내집니다. +- SMTP 권한 설정한 후, 발신자와 수신자 그리고 text를 설정하여 SMTP 서버로 요청을 보냅니다. +- 요청이 성공적으로 전송되면 200을 반환합니다. + +### 행정 구역 + +정부의 행정구역 데이터를 db에 미리 작성해두어 행정구역 데이터를 직접 관리하도록 구현하였습니다. + +> [행정표준코드관리 시스템 - 법정동코드 목록 조회](https://www.code.go.kr/stdcode/regCodeL.do) 데이터 파일을 이용하여 sql 데이터를 생성하였습니다. + +### 모집글 + +볼링 번개 모임을 모집하려는 사람이 작성하는 포스팅입니다. + +- 모집글을 등록하면, 타 유저들이 해당 모집글에 신청을 할 수 있고, 모임을 결성할 수 있습니다. +- 참가희망자들은 참가신청 기능을 이용하여 참가신청을 할 수 있고, 모집글 게시자가 참가수락 여부를 결정할 수 있습니다. +- 댓글 기능이 있어 정보 교환 및 소통이 가능합니다. +- 이후 모집글을 베이스로 참여기록, 별점, 점수등록 등이 이루어 지기 때문에모임 확정 이후에는 모집글 내용을 수정하거나 삭제할 수 없습니다. + +#### JPA Specification + +모집글 데이터가 필요한 API를 구현하는 도중, 복잡한 조건처리가 필요한 로직이 있어 동적 쿼리가 필요했습니다. `queryDSL`을 학습하여 도입하기에는 프로젝트 기간이 한정적이라 비교적 사용이 쉬운 JPA Specification을 사용하였습니다. + +- 참여기록 데이터에 적용된 `JPA Specification`의 일부 로직입니다. + + ```java + private List loadPosts(CursorRequest cursorRequest, Long userId, String condition, String status, Long cityId, String start, String end) { + int size = cursorRequest.hasSize() ? cursorRequest.size() : DEFAULT_SIZE; + Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Order.desc("id"))); + + Specification spec = Specification.where(conditionEqual(condition, userId)) + .and(statusEqual(status)) + .and(cityIdEqual(cityId)) + .and(createdAtBetween(start, end)); + + if (cursorRequest.hasKey()) { + spec = spec.and(postIdLessThan(cursorRequest.key())); + } + return postRepository.findAll(spec, pageable).getContent(); + } + ``` + + > 해당 부분에서 동적쿼리를 사용하지 않으면 30개가 넘는 조건을 처리해야 했기 때문에 `JPA Specification`을 도입하게 되었습니다. + +### 댓글 + +댓글에 대댓글을 달 수 있도록 설계하였습니다. + +- 조회는 비회원도 가능하지만 작성은 회원만 가능합니다. +- 커서 기반 페이징을 하였습니다. +- 삭제된 댓글에 대댓글이 있는 경우 대댓글 표시를 위해 전달이 됩니다. + + - 삭제된 댓글이 표시될 경우 아래와 같이 표시됩니다. + - `content: "삭제된 댓글입니다"` + - `userId: null` + +- 그외, 대댓글이 없는 삭제된 댓글과 대댓글이 삭제된 경우의 댓글은 responseBody에 포함되지 않습니다. + +> 삭제된 댓글들로 인한 통일되지 않은 응답 개수 문제가 존재합니다. +> +> 이는 프론트의 구현이 무한 스크롤로 이루어 지기 때문에 적은 개수가 응답되어도 자동으로 다음 key로 요청이 일어나 사용자 경험에 큰 영향을 주지 않을 것으로 판단되어 이렇게 구현하였습니다. + +### 신청, 별점 + +모집글 작성자 이외의 유저들은 모집글에 참여 신청을 할 수 있습니다. + +- 작성자는 신청을 수락하거나 거부할 수 있습니다. +- 작성자는 신청자 목록, 신청수락완료 유저을 확인할 수 있습니다. +- 모임 시간 이후 사용자들은 별점 등록 API를 통해 참여자들에게 별점을 줄 수 있습니다. +- 별점은 사용자의 평점에 영향을 줍니다. + +### 볼링 점수(스코어) + +모임 이후, 해당 모임에 대해 점수를 등록할 수 있습니다. + +- 점수와 함께 이미지를 등록 할 수 있어 점수에 대한 증명이 가능합니다. +- 모임 별 점수 등록은 여러개가 가능합니다. + +#### 이미지 등록 + +외부 저장소 - `AWS S3`를 사용하였습니다. + +- white listing 방식으로 확장자 검사가 이루어 집니다. +- 업로드 가능한 확장자는 png, jpg, jpeg, gif 4가지입니다. +- 이미지의 사이즈는 10MB로 제한하였습니다. + +### 프로필, 정보 + +닉네임, 이메일, 지역 정보, 매너 점수, 볼링 Avergae 점수 등을 사용자 정보로 관리합니다. + +- 타인의 프로필 조회 시 얻을 수 있는 정보 + - 닉네임 + - 매칭기록에 기반한 볼링 Average점수 + - 매너 점수 + - 지역 + - 프로필 사진 +- 자신의 프로필 조회 시 추가로 얻을 수 있는 정보 + - id(PK) + - 이메일 + - 메일인증여부 + +### 쪽지 + +다른 사용자와 1대1 대화를 할 수 있습니다. + +- 카카오톡과 같은 채팅 서비스와 비슷한 사용자경험을 주기 위해 채팅 서비스와 유사하게 구현하였습니다. +- 웹소켓을 이용한 채팅 기능으로의 변경을 염두에 두고 구현하였습니다.

카카오 테크 캠퍼스 3단계 진행 보드

@@ -36,19 +399,20 @@ 최종 배포는 크램폴린으로 배포해야 합니다. -하지만 배포 환경의 불편함이 있는 경우를 고려하여 +하지만 배포 환경의 불편함이 있는 경우를 고려하여 임의의 배포를 위해 타 배포 환경을 자유롭게 이용해도 됩니다. (단, 금액적인 지원은 어렵습니다.) 아래는 추가적인 설정을 통해 (체험판, 혹은 프리 티어 등)무료로 클라우드 배포가 가능한 서비스입니다. -ex ) AWS(아마존), GCP(구글), Azure(마이크로소프트), Cloudtype +ex ) AWS(아마존), GCP(구글), Azure(마이크로소프트), Cloudtype ``` + ## Notice ``` -필요 산출물들은 수료 기준에 영향을 주는 것은 아니지만, +필요 산출물들은 수료 기준에 영향을 주는 것은 아니지만, 주차 별 산출물을 기반으로 평가가 이루어 집니다. 주차 별 평가 점수는 추 후 최종 평가에 최종 합산 점수로 포함됩니다. @@ -59,10 +423,10 @@ ex ) AWS(아마존), GCP(구글), Azure(마이크로소프트), Cloudtype [git flowchart_FE.pdf](https://github.com/Step3-kakao-tech-campus/practice/files/12521045/git.flowchart_FE.pdf) -
## 필요 산출물 +
Step3. Week-1
@@ -130,6 +494,7 @@ ex ) AWS(아마존), GCP(구글), Azure(마이크로소프트), Cloudtype
--- +
Step3. Week-5
@@ -269,11 +634,10 @@ UI 컴포넌트의 명칭과 이를 구현하는 능력은 필수적인 커뮤 **1. PR 제목과 내용을 아래와 같이 작성 해주세요.** -> PR 제목 : 부산대_0조_아이템명_0주차 -> +PR 제목 : 부산대*0조*아이템명\_0주차
-
\ No newline at end of file +
diff --git a/build.gradle b/build.gradle index fb96d563..aa8c8e4f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,13 @@ buildscript { ext { apiServerUrl = System.getenv("API_SERVER_URL") - new File('.env').getText('UTF-8').splitEachLine(/=/) { - if (it[0] == "API_SERVER_URL") { - apiServerUrl = it[1].replaceAll('"', '') + def envFile = new File('.env') + + if (envFile.exists()) { + envFile.getText('UTF-8').splitEachLine(/=/) { + if (it[0] == "API_SERVER_URL") { + apiServerUrl = it[1].replaceAll('"', '') + } } } } @@ -63,6 +67,8 @@ dependencies { // aws implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'software.amazon.awssdk:s3:2.20.162' + implementation 'javax.xml.bind:jaxb-api:2.3.0' } test { diff --git "a/docs/\353\262\210\352\260\234\353\263\274\353\247\201_\352\270\260\355\232\215\354\225\210_v2.3.pptx" "b/docs/\353\262\210\352\260\234\353\263\274\353\247\201_\352\270\260\355\232\215\354\225\210_v2.3.pptx" new file mode 100644 index 00000000..6647ab92 Binary files /dev/null and "b/docs/\353\262\210\352\260\234\353\263\274\353\247\201_\352\270\260\355\232\215\354\225\210_v2.3.pptx" differ diff --git a/flask/Dockerfile b/flask/Dockerfile new file mode 100644 index 00000000..af5f83fe --- /dev/null +++ b/flask/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.8-alpine + +COPY . /app + +WORKDIR /app + +RUN pip install Flask-Mail + +RUN pip install flask + +RUN chmod +x /app/app.py + +CMD ["python3", "app.py"] \ No newline at end of file diff --git a/flask/app.py b/flask/app.py new file mode 100644 index 00000000..8df67459 --- /dev/null +++ b/flask/app.py @@ -0,0 +1,42 @@ +from flask import Flask, request, Response +import smtplib +from email.mime.text import MIMEText + +app = Flask(__name__) + +# HTTP POST 요청을 처리하는 엔드포인트 +@app.route('/email', methods=['POST']) +def sendEmailEndpoint(): + + try: + jsonRequest = request.get_json() + + subject = str(jsonRequest.get('subject')[0]) + text = str(jsonRequest.get('text')[0]) + email = str(jsonRequest.get('email')[0]) + username = str(jsonRequest.get('username')[0]) + password = str(jsonRequest.get('password')[0]) + + smtp = smtplib.SMTP('smtp.gmail.com', 587) + smtp.ehlo() + smtp.starttls() + smtp.login(username, password) + + msg = MIMEText(text, "html") + msg['Subject'] = subject + + smtp.sendmail(username, email, msg.as_string()) + smtp.quit() + + response = Response("Email sent successfully", status=200) + + return response + + except Exception as e: + error_message = str(e) + response = Response("Failed to send email: " + error_message, status=500) + + return response + +if __name__ == '__main__': + app.run('0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/flask/start.sh b/flask/start.sh new file mode 100644 index 00000000..e1dc92d8 --- /dev/null +++ b/flask/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Docker 이미지 빌드 +docker build -t flask-app:v1 . + +# Docker 컨테이너 실행 +docker run -d -p 80:5000 flask-app:v1 \ No newline at end of file diff --git a/k8s/backend.yaml b/k8s/backend.yaml index 4ebd548e..5dfc6020 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -15,7 +15,7 @@ spec: containers: - name: backend # 여러분의 backend image 주소를 입력해주세요. -> 빌드 후 빌드 이미지 경로 새로 넣기 - image: krmp-d2hub-idock.9rum.cc/dev-test/repo_f9940ca26849 + image: krmp-d2hub-idock.9rum.cc/dev-test/repo_64cf4065f88f env: - name: TOKEN_SECRET valueFrom: @@ -37,6 +37,11 @@ spec: secretKeyRef: name: secrets key: GMAIL_APPLICATION_PASSWORD + - name: AWS_S3_END_POINT + valueFrom: + secretKeyRef: + name: secrets + key: AWS_S3_END_POINT - name: AWS_ACCESS_KEY valueFrom: secretKeyRef: @@ -47,8 +52,13 @@ spec: secretKeyRef: name: secrets key: AWS_SECRET_KEY + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: secrets + key: MYSQL_ROOT_PASSWORD - name: MYSQL_USERNAME - valueFrom: + valueFrom: secretKeyRef: name: secrets key: MYSQL_USERNAME @@ -67,6 +77,11 @@ spec: secretKeyRef: name: secrets key: API_SERVER_URL + - name: FLASK_MAIL_SERVER + valueFrom: + secretKeyRef: + name: secrets + key: FLASK_MAIL_SERVER ports: - containerPort: 8080 resources: diff --git a/k8s/configs/default.conf b/k8s/configs/default.conf index b6b6b61b..6d32d6e0 100644 --- a/k8s/configs/default.conf +++ b/k8s/configs/default.conf @@ -1,11 +1,19 @@ server { - listen 80; + listen 80; + server_tokens off; #nginx 버전 정보 숨기기 - # location / { - # proxy_pass http://frontend.default.svc.cluster.local:3000; - # } + error_log /tmp/error.log; + access_log /tmp/access.log main; - location /api/ { - proxy_pass http://backend.default.svc.cluster.local:8080; - } -} + location / { + proxy_pass http://frontend.default.svc.cluster.local:3000; + } + + location /api/ { + proxy_pass http://backend.default.svc.cluster.local:8080; + + proxy_connect_timeout 60s; # 연결 타임아웃 설정 + proxy_send_timeout 60s; # 소켓 타임아웃 설정 + proxy_read_timeout 300s; # 프록시 서버로부터 응답을 읽어들이는 데 허용되는 시간 + } +} \ No newline at end of file diff --git a/k8s/create-k8s-secret.sh b/k8s/create-k8s-secret.sh index 561936bf..8e2dd98f 100755 --- a/k8s/create-k8s-secret.sh +++ b/k8s/create-k8s-secret.sh @@ -13,13 +13,17 @@ kubectl create secret generic $SECRET_NAME \ --from-literal=TOKEN_SECRET="$TOKEN_SECRET" \ --from-literal=GMAIL_USERNAME="$GMAIL_USERNAME" \ --from-literal=GMAIL_APPLICATION_PASSWORD="$GMAIL_APPLICATION_PASSWORD" \ + --from-literal=AWS_S3_END_POINT="$AWS_S3_END_POINT" \ --from-literal=AWS_ACCESS_KEY="$AWS_ACCESS_KEY" \ --from-literal=AWS_SECRET_KEY="$AWS_SECRET_KEY" \ + --from-literal=MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \ --from-literal=MYSQL_USERNAME="$MYSQL_USERNAME" \ --from-literal=MYSQL_PASSWORD="$MYSQL_PASSWORD" \ --from-literal=DOMAIN="$DOMAIN" \ --from-literal=GOOGLE_MAP_API_KEY="$GOOGLE_MAP_API_KEY" \ - --from-literal=API_SERVER_URL="$API_SERVER_URL" + --from-literal=API_SERVER_URL="$API_SERVER_URL" \ + --from-literal=NEXT_PUBLIC_KAKAOMAP_APPKEY="$NEXT_PUBLIC_KAKAOMAP_APPKEY" \ + --from-literal=FLASK_MAIL_SERVER="$FLASK_MAIL_SERVER" echo "Kubernetes secret $SECRET_NAME has been created or updated with the environment variables." diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml new file mode 100644 index 00000000..08c2f5c1 --- /dev/null +++ b/k8s/frontend.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend +spec: + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + # 여러분의 image 주소를 입력해주세요. + image: krmp-d2hub-idock.9rum.cc/dev-test/repo_5a8de3147f88:latest +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend +spec: + ports: + - port: 3000 + targetPort: 3000 + selector: + app: frontend \ No newline at end of file diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 00000000..227d3f39 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,18 @@ +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "false" + labels: + app.kubernetes.io/managed-by: kargocd + name: krampoline + namespace: default +spec: + rules: + - http: + paths: + - backend: + serviceName: frontend + servicePort: 3000 + path: / + pathType: Prefix \ No newline at end of file diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 03e1b54b..bc305652 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -4,6 +4,8 @@ resources: - mysql.yaml - backend.yaml - redis.yaml + - frontend.yaml + #- ingress.yaml configMapGenerator: - name: nginx files: diff --git a/k8s/mysql.yaml b/k8s/mysql.yaml index cee00c6b..966e35f7 100644 --- a/k8s/mysql.yaml +++ b/k8s/mysql.yaml @@ -20,6 +20,11 @@ spec: env: - name: TZ value: Asia/Seoul + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: secrets + key: MYSQL_ROOT_PASSWORD - name: MYSQL_USERNAME valueFrom: secretKeyRef: diff --git a/nohup.sh b/nohup.sh new file mode 100755 index 00000000..e21259e6 --- /dev/null +++ b/nohup.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# logs 폴더 생성 확인 +mkdir -p ~/logs + +# 기존에 실행 중인 Java 프로세스 종료 +PID=$(ps -ef | grep '[j]ava -jar' | awk '{print $2}') +if [ ! -z "$PID" ]; then + echo "기존 Java 프로세스 종료: $PID" + kill $PID +fi + +date=`date +%y-%m-%dT%H-%M-%S` +filePath=~/logs/springboot_nohup.$date.out + +# 애플리케이션 실행 +nohup java -jar -Dspring.profiles.active=product build/libs/server-0.0.1.jar >> "$filePath" 2>&1 & +echo "애플리케이션 실행 중..." diff --git a/src/main/java/com/bungaebowling/server/_core/config/AwsS3Config.java b/src/main/java/com/bungaebowling/server/_core/config/AwsS3Config.java index b4bb2845..ccb3b25f 100644 --- a/src/main/java/com/bungaebowling/server/_core/config/AwsS3Config.java +++ b/src/main/java/com/bungaebowling/server/_core/config/AwsS3Config.java @@ -1,16 +1,25 @@ package com.bungaebowling.server._core.config; +import com.amazonaws.ClientConfiguration; +import com.amazonaws.Protocol; import com.amazonaws.auth.AWSCredentials; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +@Slf4j @Configuration public class AwsS3Config { + @Value("${cloud.aws.s3.endpoint}") + private String endpoint; + @Value("${cloud.aws.credentials.access-key}") private String accessKey; @@ -20,7 +29,14 @@ public class AwsS3Config { @Value("${cloud.aws.region.static}") private String region; + @Value("krmp-proxy.9rum.cc") + private String proxyHost; + + @Value("3128") + private int proxyPort; + @Bean + @Profile({"local", "product", "test"}) public AmazonS3 amazonS3Client() { AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); @@ -30,4 +46,27 @@ public AmazonS3 amazonS3Client() { .withRegion(region) .build(); } -} + + @Bean + @Profile("deploy") + public AmazonS3 amazonS3ClientForDeploy() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + ClientConfiguration clientConfiguration = new ClientConfiguration(); + clientConfiguration.setConnectionTimeout(60000); // 연결 타임아웃 시간 60000ms = 60s 설정 + clientConfiguration.setSocketTimeout(60000); // 소켓 타임아웃 시간 60000ms = 60s 설정 + clientConfiguration.setProxyHost(proxyHost); + clientConfiguration.setProxyPort(proxyPort); + clientConfiguration.setProxyProtocol(Protocol.HTTP); + + AwsClientBuilder.EndpointConfiguration endpointConfiguration = new AwsClientBuilder.EndpointConfiguration(endpoint, null); + + return AmazonS3ClientBuilder + .standard() + //.withEndpointConfiguration(endpointConfiguration) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withClientConfiguration(clientConfiguration) + .withRegion(region) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/bungaebowling/server/_core/config/Configs.java b/src/main/java/com/bungaebowling/server/_core/config/Configs.java index 9ce662db..76bfdba0 100644 --- a/src/main/java/com/bungaebowling/server/_core/config/Configs.java +++ b/src/main/java/com/bungaebowling/server/_core/config/Configs.java @@ -18,9 +18,11 @@ private void setDomain(String value) { } - public final static List CORS = Collections.unmodifiableList( + public static final List CORS = Collections.unmodifiableList( List.of("http://localhost:3000", // 리액트 개발용 3000포트 - "http://127.0.0.1:3000") + "http://127.0.0.1:3000", + "https://bungae.jagaldol.dev", + "https://ka02fa9a0d9a2a.user-app.krampoline.com") ); public static List getFullCORS() { diff --git a/src/main/java/com/bungaebowling/server/_core/config/MailConfig.java b/src/main/java/com/bungaebowling/server/_core/config/MailConfig.java index 6183d0f5..0cc4bb7d 100644 --- a/src/main/java/com/bungaebowling/server/_core/config/MailConfig.java +++ b/src/main/java/com/bungaebowling/server/_core/config/MailConfig.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; @@ -20,6 +21,7 @@ public class MailConfig { private String password; @Bean + @Profile({"local", "product", "test", "deploy"}) public JavaMailSender javaMailService() { JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); diff --git a/src/main/java/com/bungaebowling/server/_core/config/RestTemplateConfig.java b/src/main/java/com/bungaebowling/server/_core/config/RestTemplateConfig.java new file mode 100644 index 00000000..b55fcf7f --- /dev/null +++ b/src/main/java/com/bungaebowling/server/_core/config/RestTemplateConfig.java @@ -0,0 +1,37 @@ +package com.bungaebowling.server._core.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +@Configuration +public class RestTemplateConfig { + + @Value("krmp-proxy.9rum.cc") + private String proxyHost; + + @Value("3128") + private int proxyPort; + + @Bean + @Profile("deploy") + public RestTemplate restTemplateForDeploy() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); + requestFactory.setProxy(proxy); + + return new RestTemplate(requestFactory); + } + + @Bean + @Profile({"local", "product", "test"}) + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/bungaebowling/server/_core/errors/GlobalExceptionHandler.java b/src/main/java/com/bungaebowling/server/_core/errors/GlobalExceptionHandler.java index c978717f..133a4119 100644 --- a/src/main/java/com/bungaebowling/server/_core/errors/GlobalExceptionHandler.java +++ b/src/main/java/com/bungaebowling/server/_core/errors/GlobalExceptionHandler.java @@ -3,13 +3,11 @@ import com.bungaebowling.server._core.errors.exception.CustomException; import com.bungaebowling.server._core.errors.exception.ErrorCode; import com.bungaebowling.server._core.utils.ApiUtils; -import lombok.extern.slf4j.Slf4j; 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 public class GlobalExceptionHandler { @ExceptionHandler(CustomException.class) @@ -19,7 +17,6 @@ public ResponseEntity customError(CustomException e) { @ExceptionHandler(Exception.class) public ResponseEntity unknownServerError(Exception e) { - log.error("unknown 에러 발생", e); var status = HttpStatus.INTERNAL_SERVER_ERROR; var response = ApiUtils.error(e.getMessage(), ErrorCode.UNKNOWN_SERVER_ERROR); return ResponseEntity.status(status).body(response); diff --git a/src/main/java/com/bungaebowling/server/_core/errors/exception/ErrorCode.java b/src/main/java/com/bungaebowling/server/_core/errors/exception/ErrorCode.java index 66d53588..32b48e5d 100644 --- a/src/main/java/com/bungaebowling/server/_core/errors/exception/ErrorCode.java +++ b/src/main/java/com/bungaebowling/server/_core/errors/exception/ErrorCode.java @@ -17,6 +17,7 @@ public enum ErrorCode { FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패하였습니다."), INVALID_FILE_UPLOAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 파일 업로드 요청입니다."), INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "허용되지 않는 파일 확장자입니다."), + FILE_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "잘못된 파일 요청입니다."), SCORE_UPLOAD_FAILED(HttpStatus.BAD_REQUEST, "점수 등록에 실패하였습니다."), SCORE_UPDATE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "점수 정보에 대한 수정 권한이 없습니다."), @@ -28,6 +29,7 @@ public enum ErrorCode { POST_NOT_CLOSE(HttpStatus.FORBIDDEN, "모집글이 마감되지 않았습니다."), POST_UPDATE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "모집글에 대한 수정 권한이 없습니다."), POST_DELETE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "모집글에 대한 삭제 권한이 없습니다."), + POST_DELETE_NOT_ALLOWED(HttpStatus.FORBIDDEN, "마감된 모집글은 삭제할 수 없습니다."), POST_CLOSE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "모집글에 대한 마감 권한이 없습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), @@ -51,6 +53,7 @@ public enum ErrorCode { USER_EMAIL_DUPLICATED(HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), USER_NAME_DUPLICATED(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + USER_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "프로필 수정에 실패하였습니다."), WRONG_PASSWORD(HttpStatus.BAD_REQUEST, "기존 비밀번호가 일치하지 않습니다."), EMAIL_SEND_LIMIT_EXCEEDED(HttpStatus.INTERNAL_SERVER_ERROR, "서버 이메일 전송 한도가 초과되었습니다. 내일 다시 시도해주세요."), diff --git a/src/main/java/com/bungaebowling/server/_core/utils/AwsS3Service.java b/src/main/java/com/bungaebowling/server/_core/utils/AwsS3Service.java index 542a8b72..f8e47268 100644 --- a/src/main/java/com/bungaebowling/server/_core/utils/AwsS3Service.java +++ b/src/main/java/com/bungaebowling/server/_core/utils/AwsS3Service.java @@ -35,6 +35,13 @@ public class AwsS3Service { @Value("${spring.servlet.multipart.max-request-size}") private Long totalFilesMaxSize; + private final List allowedExtensions = List.of( + ".png", + ".gif", + ".jpeg", + ".jpg" + ); + // 점수 단일 파일용 public String uploadScoreFile(Long userId, Long postId, String category, LocalDateTime time, MultipartFile multipartFile) { String fileName = CommonUtils.buildScoreFileName(userId, postId, category, time, Objects.requireNonNull(multipartFile.getOriginalFilename())); @@ -106,6 +113,8 @@ public String getImageAccessUrl(String fileName) { private void uploadFileToS3(String fileName, MultipartFile multipartFile) { ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentType(multipartFile.getContentType()); + objectMetadata.setContentLength(multipartFile.getSize()); + try (InputStream inputStream = multipartFile.getInputStream()) { amazonS3Client.putObject( @@ -133,15 +142,10 @@ private String fileWhiteList(String fileName) { } String caseInSensitiveFileName = fileName.toLowerCase(); - if ( - caseInSensitiveFileName.endsWith(".png") || - caseInSensitiveFileName.endsWith(".gif") || - caseInSensitiveFileName.endsWith(".jpeg") || - caseInSensitiveFileName.endsWith(".jpg") - ) { - return caseInSensitiveFileName; - } else { + var isNotAllowedExtension = allowedExtensions.stream().noneMatch(caseInSensitiveFileName::endsWith); + if (isNotAllowedExtension) throw new CustomException(ErrorCode.INVALID_FILE_EXTENSION); - } + + return caseInSensitiveFileName; } -} +} \ No newline at end of file diff --git a/src/main/java/com/bungaebowling/server/_core/utils/CommonUtils.java b/src/main/java/com/bungaebowling/server/_core/utils/CommonUtils.java index a0acfbca..4b2ff221 100644 --- a/src/main/java/com/bungaebowling/server/_core/utils/CommonUtils.java +++ b/src/main/java/com/bungaebowling/server/_core/utils/CommonUtils.java @@ -1,6 +1,10 @@ package com.bungaebowling.server._core.utils; +import com.bungaebowling.server._core.errors.exception.CustomException; +import com.bungaebowling.server._core.errors.exception.ErrorCode; + import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; public class CommonUtils { private static final String FILE_EXTENSION_SEPARATOR = "."; @@ -10,27 +14,34 @@ public class CommonUtils { // 점수 등록용 public static String buildScoreFileName(Long userId, Long postId, String category, LocalDateTime time, String originalFileName) { - int fileExtensionIndex = originalFileName.lastIndexOf(FILE_EXTENSION_SEPARATOR); // 파일 확장자 구분선 + int fileExtensionIndex = getFileExtensionIndex(originalFileName); String fileExtension = originalFileName.substring(fileExtensionIndex); // 파일 확장자 - String fileName = originalFileName.substring(0, fileExtensionIndex); // 파일 이름 String now = String.valueOf(time); // 파일 업로드 시간 // 작성자/게시글ID/score/파일명/파일업로드시간.확장자 -> 이런 방식으로 저장됨 - return "user" + WORD_SEPARATOR + userId + CATEGORY_PREFIX + postId + CATEGORY_PREFIX + category + CATEGORY_PREFIX + fileName + TIME_SEPARATOR + now + fileExtension; + return "user" + WORD_SEPARATOR + userId + CATEGORY_PREFIX + postId + CATEGORY_PREFIX + category + CATEGORY_PREFIX + now + fileExtension; } //프로필 등록 public static String buildProfileFileName(Long userId, String category, LocalDateTime time, String originalFileName) { - int fileExtensionIndex = originalFileName.lastIndexOf(FILE_EXTENSION_SEPARATOR); // 파일 확장자 구분선 + int fileExtensionIndex = getFileExtensionIndex(originalFileName); String fileExtension = originalFileName.substring(fileExtensionIndex); // 파일 확장자 - String fileName = originalFileName.substring(0, fileExtensionIndex); // 파일 이름 String now = String.valueOf(time); // 파일 업로드 시간 //작성자(user_1)/profile/파일명/파일업로드시간.확장자 - return "user" + WORD_SEPARATOR + userId + CATEGORY_PREFIX + category + CATEGORY_PREFIX + fileName + TIME_SEPARATOR + now + fileExtension; + return "user" + WORD_SEPARATOR + userId + CATEGORY_PREFIX + category + CATEGORY_PREFIX + now + fileExtension; + } + + private static int getFileExtensionIndex(String originalFileName) { + int fileExtensionIndex = originalFileName.lastIndexOf(FILE_EXTENSION_SEPARATOR); // 파일 확장자 구분선 + + if (fileExtensionIndex == -1) { + throw new CustomException(ErrorCode.FILE_REQUEST_FAILED); + } + return fileExtensionIndex; } - // 단일 파일용 -> 이것도 사용 용도에 맞게 custom해서 써야 함 + // 단일 파일용 Template public static String buildFileName(String category, String originalFileName) { int fileExtensionIndex = originalFileName.lastIndexOf(FILE_EXTENSION_SEPARATOR); // 파일 확장자 구분선 String fileExtension = originalFileName.substring(fileExtensionIndex); // 파일 확장자 diff --git a/src/main/java/com/bungaebowling/server/applicant/repository/ApplicantRepository.java b/src/main/java/com/bungaebowling/server/applicant/repository/ApplicantRepository.java index cfe470ea..66752012 100644 --- a/src/main/java/com/bungaebowling/server/applicant/repository/ApplicantRepository.java +++ b/src/main/java/com/bungaebowling/server/applicant/repository/ApplicantRepository.java @@ -1,6 +1,7 @@ package com.bungaebowling.server.applicant.repository; import com.bungaebowling.server.applicant.Applicant; +import com.bungaebowling.server.post.Post; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -32,4 +33,8 @@ public interface ApplicantRepository extends JpaRepository { @Query("SELECT a FROM Applicant a JOIN FETCH a.user u WHERE a.post.id = :postId and a.status = true ORDER BY u.id DESC") List findAllByPostIdAndStatusTrueOrderByUserIdDesc(@Param("postId") Long postId); + + List findAllByPost(Post post); + + void deleteAllByPost(Post post); } \ No newline at end of file diff --git a/src/main/java/com/bungaebowling/server/comment/repository/CommentRepository.java b/src/main/java/com/bungaebowling/server/comment/repository/CommentRepository.java index 32dfe8a4..9efa18d3 100644 --- a/src/main/java/com/bungaebowling/server/comment/repository/CommentRepository.java +++ b/src/main/java/com/bungaebowling/server/comment/repository/CommentRepository.java @@ -2,6 +2,7 @@ import com.bungaebowling.server.comment.Comment; +import com.bungaebowling.server.post.Post; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -34,4 +35,6 @@ public interface CommentRepository extends JpaRepository { @Query("SELECT c FROM Comment c WHERE c.id = :id AND c.post.id = :postId AND c.parent = null") Optional findByIdAndPostIdAndParentNull(@Param("id") Long id, @Param("postId") Long postId); + + void deleteAllByPost(Post post); } diff --git a/src/main/java/com/bungaebowling/server/post/dto/PostResponse.java b/src/main/java/com/bungaebowling/server/post/dto/PostResponse.java index cf4101a0..e6f31cfd 100644 --- a/src/main/java/com/bungaebowling/server/post/dto/PostResponse.java +++ b/src/main/java/com/bungaebowling/server/post/dto/PostResponse.java @@ -113,7 +113,8 @@ public static GetParticipationRecordsDto of( Map> members, Map> rates, Map applicantIdMap, - Map currentNumberMap + Map currentNumberMap, + Map districtNameMap ) { return new GetParticipationRecordsDto( nextCursorRequest, @@ -124,7 +125,8 @@ public static GetParticipationRecordsDto of( members.get(post.getId()), rates.get(post.getId()), applicantIdMap.get(post.getId()), - currentNumberMap.get(post.getId()) + currentNumberMap.get(post.getId()), + districtNameMap.get(post.getId()) )).toList() ); } @@ -141,13 +143,21 @@ public record PostDto( List scores, List members ) { - public PostDto(Post post, List scores, List users, List rates, Long applicantId, Long currentNumber) { + public PostDto( + Post post, + List scores, + List users, + List rates, + Long applicantId, + Long currentNumber, + String districtName + ) { this( post.getId(), applicantId, post.getTitle(), post.getDueTime(), - post.getDistrictName(), + districtName, post.getStartTime(), currentNumber, post.getIsClose(), diff --git a/src/main/java/com/bungaebowling/server/post/service/PostService.java b/src/main/java/com/bungaebowling/server/post/service/PostService.java index f7ff501c..359370d9 100644 --- a/src/main/java/com/bungaebowling/server/post/service/PostService.java +++ b/src/main/java/com/bungaebowling/server/post/service/PostService.java @@ -7,6 +7,7 @@ import com.bungaebowling.server.applicant.repository.ApplicantRepository; import com.bungaebowling.server.city.country.district.District; import com.bungaebowling.server.city.country.district.repository.DistrictRepository; +import com.bungaebowling.server.comment.repository.CommentRepository; import com.bungaebowling.server.post.Post; import com.bungaebowling.server.post.dto.PostRequest; import com.bungaebowling.server.post.dto.PostResponse; @@ -44,6 +45,7 @@ public class PostService { private final ScoreRepository scoreRepository; private final ApplicantRepository applicantRepository; private final UserRateRepository userRateRepository; + private final CommentRepository commentRepository; public static final int DEFAULT_SIZE = 20; @@ -141,7 +143,9 @@ private List findPosts(CursorRequest cursorRequest, Long cityId, Long coun public void update(Long userId, Long postId, PostRequest.UpdatePostDto request) { Post post = findById(postId); // post 찾는 코드 빼서 함수화 - + if (post.getIsClose()) { + throw new CustomException(ErrorCode.POST_DELETE_NOT_ALLOWED, "마감된 모집글은 수정할 수 없습니다."); + } if (!post.isMine(userId)) { throw new CustomException(ErrorCode.POST_UPDATE_PERMISSION_DENIED); } @@ -161,11 +165,12 @@ public void update(Long userId, Long postId, PostRequest.UpdatePostDto request) public void delete(Long userId, Long postId) { Post post = findById(postId); // post 찾는 코드 빼서 함수화 - + if (post.getIsClose()) { + throw new CustomException(ErrorCode.POST_DELETE_NOT_ALLOWED); + } if (!post.isMine(userId)) { throw new CustomException(ErrorCode.POST_DELETE_PERMISSION_DENIED); } - deletePost(post); } @@ -190,6 +195,7 @@ public PostResponse.GetParticipationRecordsDto getParticipationRecords(CursorReq Map> rateMap = getRateMap(userId, posts, applicantMap); Map applicantIdMap = getApplicantIdMap(userId, posts, applicantMap); Map currentNumberMap = getCurrentNumberMap(posts, applicantMap); + Map districtNameMap = getDistrictNameMap(posts); Long lastKey = getLastKey(posts); return PostResponse.GetParticipationRecordsDto.of( @@ -199,7 +205,8 @@ public PostResponse.GetParticipationRecordsDto getParticipationRecords(CursorReq memberMap, rateMap, applicantIdMap, - currentNumberMap + currentNumberMap, + districtNameMap ); } @@ -274,7 +281,25 @@ private Map getCurrentNumberMap(List posts, Map getDistrictNameMap(List posts) { + return posts.stream().collect(Collectors.toMap( + Post::getId, + post -> { + District district = districtRepository.findById(post.getDistrict().getId()) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + return district.getCountry().getCity().getName() + " " + + district.getCountry().getName() + " " + + district.getName(); + } + )); + } + private void deletePost(Post post) { // 삭제 로직 따로 분리 + List applicants = applicantRepository.findAllByPost(post); + applicants.stream().forEach(applicant -> userRateRepository.deleteAllByApplicant(applicant)); + applicantRepository.deleteAllByPost(post); + commentRepository.deleteAllByPost(post); + scoreRepository.deleteAllByPost(post); postRepository.delete(post); } diff --git a/src/main/java/com/bungaebowling/server/post/service/PostSpecification.java b/src/main/java/com/bungaebowling/server/post/service/PostSpecification.java index 127d4224..593de926 100644 --- a/src/main/java/com/bungaebowling/server/post/service/PostSpecification.java +++ b/src/main/java/com/bungaebowling/server/post/service/PostSpecification.java @@ -1,8 +1,6 @@ package com.bungaebowling.server.post.service; import com.bungaebowling.server.applicant.Applicant; -import com.bungaebowling.server.city.country.Country; -import com.bungaebowling.server.city.country.district.District; import com.bungaebowling.server.post.Post; import com.bungaebowling.server.user.User; import jakarta.persistence.criteria.*; @@ -19,9 +17,6 @@ public class PostSpecification { public static Specification conditionEqual(String condition, Long userId) { return (root, query, criteriaBuilder) -> { Join userJoin = root.join("user", JoinType.LEFT); - Fetch districtFetch = root.fetch("district", JoinType.LEFT); - Fetch countryFetch = districtFetch.fetch("country", JoinType.LEFT); - countryFetch.fetch("city", JoinType.LEFT); Subquery subquery = query.subquery(Long.class); Root applicantRoot = subquery.from(Applicant.class); diff --git a/src/main/java/com/bungaebowling/server/score/Score.java b/src/main/java/com/bungaebowling/server/score/Score.java index be73f9b3..fee99c92 100644 --- a/src/main/java/com/bungaebowling/server/score/Score.java +++ b/src/main/java/com/bungaebowling/server/score/Score.java @@ -63,4 +63,8 @@ public void updateWithFile(String resultImageUrl, String accessImageUrl) { public void updateScoreNum(Integer scoreNum) { this.scoreNum = scoreNum; } + + public Boolean isMine(Long userId) { // 내 점수 인지 아닌지 확인 + return this.user.getId().equals(userId); + } } diff --git a/src/main/java/com/bungaebowling/server/score/controller/ScoreController.java b/src/main/java/com/bungaebowling/server/score/controller/ScoreController.java index 1ba42d3d..99025375 100644 --- a/src/main/java/com/bungaebowling/server/score/controller/ScoreController.java +++ b/src/main/java/com/bungaebowling/server/score/controller/ScoreController.java @@ -18,14 +18,17 @@ public class ScoreController { private final ScoreService scoreService; @GetMapping("/{postId}/scores") - public ResponseEntity getScores(@PathVariable Long postId) { - ScoreResponse.GetScoresDto response = scoreService.readScores(postId); + public ResponseEntity getScores( + @PathVariable Long postId, + @RequestParam(value = "userId", required = false) Long userId + ) { + ScoreResponse.GetScoresDto response = scoreService.readScores(postId, userId); return ResponseEntity.ok().body(ApiUtils.success(response)); } // multipart/form-data를 처리하고 json을 반환 - @PostMapping(value = "/{postId}/scores", produces = "application/json", consumes = "multipart/form-data") + @PostMapping(value = "/{postId}/scores", produces = "application/json", consumes = {"multipart/form-data"}) public ResponseEntity createScore( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long postId, @@ -37,7 +40,7 @@ public ResponseEntity createScore( return ResponseEntity.ok().body(ApiUtils.success()); } - @PutMapping(value = "/{postId}/scores/{scoreId}", produces = "application/json", consumes = "multipart/form-data") + @PutMapping(value = "/{postId}/scores/{scoreId}", produces = "application/json", consumes = {"multipart/form-data"}) public ResponseEntity updateScore( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long postId, @@ -56,7 +59,7 @@ public ResponseEntity deleteScoreImage( @PathVariable Long postId, @PathVariable Long scoreId ) { - scoreService.deleteImage(userDetails.getId(), postId, scoreId); + scoreService.deleteImage(userDetails.getId(), scoreId); return ResponseEntity.ok(ApiUtils.success()); } @@ -67,8 +70,8 @@ public ResponseEntity deleteScore( @PathVariable Long postId, @PathVariable Long scoreId ) { - scoreService.delete(userDetails.getId(), postId, scoreId); + scoreService.delete(userDetails.getId(), scoreId); return ResponseEntity.ok(ApiUtils.success()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/bungaebowling/server/score/repository/ScoreRepository.java b/src/main/java/com/bungaebowling/server/score/repository/ScoreRepository.java index 7ef713d5..bd430ccd 100644 --- a/src/main/java/com/bungaebowling/server/score/repository/ScoreRepository.java +++ b/src/main/java/com/bungaebowling/server/score/repository/ScoreRepository.java @@ -1,5 +1,6 @@ package com.bungaebowling.server.score.repository; +import com.bungaebowling.server.post.Post; import com.bungaebowling.server.score.Score; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -12,8 +13,13 @@ public interface ScoreRepository extends JpaRepository { List findAllByPostId(Long postId); + @Query("SELECT s FROM Score s WHERE s.post.id = :postId AND s.user.id = :userId") + List findAllByPostIdAndUserId(Long postId, Long userId); + List findAllByUserId(Long userId); @Query("SELECT s FROM Score s JOIN FETCH s.user u WHERE u.id = :userId and s.post.id = :postId ORDER BY s.id ASC") List findAllByUserIdAndPostIdOrderById(@Param("userId") Long userId, @Param("postId") Long postId); + + void deleteAllByPost(Post post); } \ No newline at end of file diff --git a/src/main/java/com/bungaebowling/server/score/service/ScoreService.java b/src/main/java/com/bungaebowling/server/score/service/ScoreService.java index 74919329..c5f5660d 100644 --- a/src/main/java/com/bungaebowling/server/score/service/ScoreService.java +++ b/src/main/java/com/bungaebowling/server/score/service/ScoreService.java @@ -17,7 +17,6 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; @Transactional(readOnly = true) @RequiredArgsConstructor @@ -30,13 +29,13 @@ public class ScoreService { private final UserRepository userRepository; private final PostRepository postRepository; - public ScoreResponse.GetScoresDto readScores(Long postId) { - List scores = findScores(postId); + public ScoreResponse.GetScoresDto readScores(Long postId, Long userId) { + List scores = findScores(postId, userId); return ScoreResponse.GetScoresDto.of(scores); } - private List findScores(Long postId) { - return scoreRepository.findAllByPostId(postId); + private List findScores(Long postId, Long userId) { + return userId == null ? scoreRepository.findAllByPostId(postId) : scoreRepository.findAllByPostIdAndUserId(postId, userId); } @Transactional @@ -102,12 +101,10 @@ private void saveScoreWithImage(Long userId, Post post, Integer scoreNum, Multip @Transactional public void update(Long userId, Long postId, Long scoreId, Integer scoreNum, MultipartFile image) { - Post post = findPostById(postId); - - checkPostPermission(userId, post); - Score score = findScoreById(scoreId); + checkScoreUpdatePermission(userId, score); + updateScore(scoreNum, image, postId, userId, score); } @@ -143,13 +140,11 @@ private void validateScoreNum(Integer scoreNum) { } @Transactional - public void deleteImage(Long userId, Long postId, Long scoreId) { - Post post = findPostById(postId); - - checkPostPermission(userId, post); - + public void deleteImage(Long userId, Long scoreId) { Score score = findScoreById(scoreId); + checkScoreDeletePermission(userId, score); + checkImageExist(score); deleteScoreImage(score); @@ -162,22 +157,26 @@ private void deleteScoreImage(Score score) { } @Transactional - public void delete(Long userId, Long postId, Long scoreId) { - Post post = findPostById(postId); - - checkPostPermission(userId, post); - + public void delete(Long userId, Long scoreId) { Score score = findScoreById(scoreId); + checkScoreDeletePermission(userId, score); + deleteScore(score); } - private void checkPostPermission(Long userId, Post post) { - if (!post.isMine(userId)) { + private void checkScoreDeletePermission(Long userId, Score score) { + if (!score.isMine(userId)) { throw new CustomException(ErrorCode.SCORE_DELETE_PERMISSION_DENIED); } } + private void checkScoreUpdatePermission(Long userId, Score score) { + if (!score.isMine(userId)) { + throw new CustomException(ErrorCode.SCORE_UPDATE_PERMISSION_DENIED); + } + } + private void deleteScore(Score score) { deleteImageIfExist(score); scoreRepository.delete(score); @@ -215,4 +214,4 @@ public int findMinScore(List scores) { .min() .orElse(0); } -} +} \ No newline at end of file diff --git a/src/main/java/com/bungaebowling/server/user/controller/UserController.java b/src/main/java/com/bungaebowling/server/user/controller/UserController.java index db47f2a2..aea51af9 100644 --- a/src/main/java/com/bungaebowling/server/user/controller/UserController.java +++ b/src/main/java/com/bungaebowling/server/user/controller/UserController.java @@ -27,7 +27,7 @@ @RequestMapping("/api") public class UserController { - final private UserService userService; + private final UserService userService; @PostMapping("/join") public ResponseEntity join(@RequestBody @Valid UserRequest.JoinDto requestDto, Errors errors) throws URISyntaxException { @@ -58,8 +58,10 @@ public ResponseEntity logout(@AuthenticationPrincipal CustomUserDetails userD userService.logout(userDetails.getId()); ResponseCookie responseCookie = ResponseCookie.from("refreshToken", "") - .maxAge(0) + .httpOnly(true) // javascript 접근 방지 + .secure(true) // https 통신 강제 .sameSite("None") + .maxAge(0) .build(); var response = ApiUtils.success(); diff --git a/src/main/java/com/bungaebowling/server/user/dto/UserRequest.java b/src/main/java/com/bungaebowling/server/user/dto/UserRequest.java index 718e8e9c..5b8ef338 100644 --- a/src/main/java/com/bungaebowling/server/user/dto/UserRequest.java +++ b/src/main/java/com/bungaebowling/server/user/dto/UserRequest.java @@ -1,6 +1,7 @@ package com.bungaebowling.server.user.dto; import com.bungaebowling.server.city.country.district.District; +import com.bungaebowling.server.user.Role; import com.bungaebowling.server.user.User; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/com/bungaebowling/server/user/rate/repository/UserRateRepository.java b/src/main/java/com/bungaebowling/server/user/rate/repository/UserRateRepository.java index 5c2f0455..68e7f725 100644 --- a/src/main/java/com/bungaebowling/server/user/rate/repository/UserRateRepository.java +++ b/src/main/java/com/bungaebowling/server/user/rate/repository/UserRateRepository.java @@ -1,5 +1,6 @@ package com.bungaebowling.server.user.rate.repository; +import com.bungaebowling.server.applicant.Applicant; import com.bungaebowling.server.user.rate.UserRate; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -13,4 +14,7 @@ public interface UserRateRepository extends JpaRepository { @Query("SELECT ur FROM UserRate ur JOIN FETCH ur.user u WHERE ur.applicant.id = :applicantId") List findAllByApplicantId(@Param("applicantId") Long applicantId); + + void deleteAllByApplicant(Applicant applicant); + } diff --git a/src/main/java/com/bungaebowling/server/user/repository/UserRepository.java b/src/main/java/com/bungaebowling/server/user/repository/UserRepository.java index 7326d219..f2538744 100644 --- a/src/main/java/com/bungaebowling/server/user/repository/UserRepository.java +++ b/src/main/java/com/bungaebowling/server/user/repository/UserRepository.java @@ -13,6 +13,8 @@ public interface UserRepository extends JpaRepository { Optional findByName(String name); + Boolean existsByName(String name); + List findAllByNameContainingOrderByIdDesc(@Param("name") String name, Pageable pageable); List findAllByNameContainingAndIdLessThanOrderByIdDesc(@Param("name") String name, @Param("key") Long key, Pageable pageable); diff --git a/src/main/java/com/bungaebowling/server/user/service/UserService.java b/src/main/java/com/bungaebowling/server/user/service/UserService.java index f52b96dc..db3e64bb 100644 --- a/src/main/java/com/bungaebowling/server/user/service/UserService.java +++ b/src/main/java/com/bungaebowling/server/user/service/UserService.java @@ -22,18 +22,26 @@ import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import java.security.SecureRandom; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -57,12 +65,21 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final JavaMailSender javaMailSender; + private final RestTemplate restTemplate; private final AwsS3Service awsS3Service; private final ScoreService scoreService; + private final Environment environment; + @Value("${bungaebowling.domain}") private String domain; + @Value("${mail.server}") + private String mailServer; + @Value("${mail.username}") + private String username; + @Value("${mail.password}") + private String password; @Transactional public UserResponse.JoinDto join(UserRequest.JoinDto requestDto) { @@ -147,6 +164,35 @@ public void sendVerificationMail(Long userId) { String subject = "[번개볼링] 이메일 인증을 완료해주세요."; String text = "링크를 클릭하여 인증을 완료해주세요!"; + if (Arrays.asList(environment.getActiveProfiles()).contains("deploy")) { + sendMailToMailServer(user, subject, text); + } else { + sendMail(user, subject, text); + } + } + + private void sendMailToMailServer(User user, String subject, String text) { + try { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + + MultiValueMap requests = new LinkedMultiValueMap<>(); + requests.add("subject", subject); + requests.add("text", text); + requests.add("email", user.getEmail()); + requests.add("username", username); + requests.add("password", password); + + HttpEntity> request = new HttpEntity<>(requests, httpHeaders); + String requestURL = "http://" + mailServer + "/email"; + + restTemplate.postForEntity(requestURL, request, String.class); + } catch (Exception e) { + throw new CustomException(ErrorCode.EMAIL_SEND_LIMIT_EXCEEDED); + } + } + + private void sendMail(User user, String subject, String text) { try { MimeMessage mimeMessage = javaMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "utf-8"); @@ -210,15 +256,23 @@ public void updateMyProfile(MultipartFile profileImage, String name, Long distri User user = findUserById(userId); + if (userRepository.existsByName(name)) { + throw new CustomException(ErrorCode.USER_NAME_DUPLICATED); + } + District district = districtId == null ? null : districtRepository.findById(districtId).orElseThrow( () -> new CustomException(ErrorCode.REGION_NOT_FOUND) ); - if (profileImage == null) { - user.updateProfile(name, district, null, null); - } else { - updateProfileWithImage(user, name, district, profileImage); + try { + if (profileImage == null) { + user.updateProfile(name, district, null, null); + } else { + updateProfileWithImage(user, name, district, profileImage); + } + } catch (Exception e) { + throw new CustomException(ErrorCode.USER_UPDATE_FAILED); } } @@ -263,15 +317,10 @@ public void sendVerificationMailForPasswordReset(UserRequest.SendVerificationMai String subject = "[번개볼링] 비밀번호 초기화 및 임시 비밀번호 발급을 위한 이메일 인증을 완료해주세요."; String text = "링크를 클릭하여 인증을 완료해주세요!"; - try { - MimeMessage mimeMessage = javaMailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "utf-8"); - helper.setTo(user.getEmail()); - helper.setSubject(subject); - helper.setText(text, true); - javaMailSender.send(mimeMessage); - } catch (Exception e) { - throw new CustomException(ErrorCode.EMAIL_SEND_LIMIT_EXCEEDED); + if (Arrays.asList(environment.getActiveProfiles()).contains("deploy")) { + sendMailToMailServer(user, subject, text); + } else { + sendMail(user, subject, text); } } @@ -285,17 +334,11 @@ public void confirmEmailAndSendTempPassword(UserRequest.ConfirmEmailAndSendTempP String subject = "[번개볼링] 임시 비밀번호"; String text = "임시 비밀번호는 " + tempPassword + " 입니다.
*비밀번호를 변경해주세요." + "
*기존의 비밀번호는 사용할 수 없습니다."; - try { - MimeMessage mimeMessage = javaMailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "utf-8"); - helper.setTo(user.getEmail()); - helper.setSubject(subject); - helper.setText(text, true); - javaMailSender.send(mimeMessage); - } catch (Exception e) { - throw new CustomException(ErrorCode.EMAIL_SEND_LIMIT_EXCEEDED); + if (Arrays.asList(environment.getActiveProfiles()).contains("deploy")) { + sendMailToMailServer(user, subject, text); + } else { + sendMail(user, subject, text); } - } public UserResponse.GetRecordDto getRecords(Long userId) { @@ -346,8 +389,5 @@ public String getRamdomPassword(int length) { } return stringBuilder.toString(); - } - - } \ No newline at end of file diff --git a/src/main/resources/application-deploy.yml b/src/main/resources/application-deploy.yml index 2d54e8d4..6ca18ac8 100644 --- a/src/main/resources/application-deploy.yml +++ b/src/main/resources/application-deploy.yml @@ -43,7 +43,7 @@ logging: # jwt token config bungaebowling: token_exp: - access: 172800 + access: 600 refresh: 2592000 secret: ${TOKEN_SECRET} domain: ${DOMAIN} @@ -55,6 +55,7 @@ mail: port: 587 username: ${GMAIL_USERNAME} password: ${GMAIL_APPLICATION_PASSWORD} + server: ${FLASK_MAIL_SERVER} # aws s3 config cloud: @@ -63,6 +64,7 @@ cloud: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} s3: + endpoint: ${AWS_S3_END_POINT} bucket: bungaebowling-img-s3 region: static: ap-northeast-2 @@ -73,4 +75,4 @@ cloud: google: api: places: - key: ${GOOGLE_MAP_API_KEY} + key: ${GOOGLE_MAP_API_KEY} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index a6af6627..60ebd2f3 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -61,6 +61,7 @@ mail: port: 587 username: ${GMAIL_USERNAME} password: ${GMAIL_APPLICATION_PASSWORD} + server: flask-mail-server # aws s3 config cloud: @@ -69,6 +70,7 @@ cloud: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} s3: + endpoint: aws-s3-endpoint bucket: bungaebowling-img-s3 region: static: ap-northeast-2 diff --git a/src/main/resources/application-product.yml b/src/main/resources/application-product.yml index 821adfd2..ad18383e 100644 --- a/src/main/resources/application-product.yml +++ b/src/main/resources/application-product.yml @@ -49,7 +49,7 @@ logging: # jwt token config bungaebowling: token_exp: - access: 172800 + access: 600 refresh: 2592000 secret: ${TOKEN_SECRET} domain: ${DOMAIN} @@ -61,6 +61,7 @@ mail: port: 587 username: ${GMAIL_USERNAME} password: ${GMAIL_APPLICATION_PASSWORD} + server: flask-mail-server # aws s3 config cloud: @@ -69,6 +70,7 @@ cloud: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} s3: + endpoint: aws-s3-endpoint bucket: bungaebowling-img-s3 region: static: ap-northeast-2 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 6065021d..701bf7ec 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -4,6 +4,7 @@ server: charset: utf-8 force: true port: 8080 + spring: datasource: url: jdbc:h2:mem:test;MODE=MySQL @@ -62,6 +63,7 @@ mail: port: 587 username: bungaebowling55@gmail.com password: gmailApplicationPassword + server: flask-mail-server # aws s3 config cloud: @@ -70,6 +72,7 @@ cloud: access-key: aws-access-key secret-key: aws-secret-key s3: + endpoint: aws-s3-endpoint bucket: bungaebowling-img-s3 region: static: ap-northeast-2 diff --git a/src/main/resources/test_db/teardown.sql b/src/main/resources/test_db/teardown.sql index 697bb63a..4bce1b1f 100644 --- a/src/main/resources/test_db/teardown.sql +++ b/src/main/resources/test_db/teardown.sql @@ -745,35 +745,36 @@ VALUES ('불금 볼링 점수 내기 하실 분~', 1, 1, '2023-12-01', '2023-11- ('불금 볼링 점수 내기 하실 분~', 4, 2, '2023-08-01', '2023-11-29', '볼링 점수 내기합시다.', false), ('불금 볼링 점수 내기 하실 분~', 4, 2, '2023-08-01', '2023-11-29', '볼링 점수 내기합시다.', true); -INSERT INTO applicant_tb (user_id, post_id, status) -VALUES (1, 1, true), - (1, 2, true), - (1, 3, true), - (1, 4, true), - (1, 5, true), - (3, 6, true), - (3, 7, true), - (3, 8, true), - (3, 9, true), - (3, 10, true), - (4, 11, true), - (4, 12, true), - (4, 13, true), - (4, 14, true), - (4, 15, true), +INSERT INTO applicant_tb (id, user_id, post_id, status) +VALUES (1, 1, 1, true), + (2, 1, 2, true), + (3, 1, 3, true), + (4, 1, 4, true), + (5, 1, 5, true), + (6, 3, 6, true), + (7, 3, 7, true), + (8, 3, 8, true), + (9, 3, 9, true), + (10, 3, 10, true), + (11, 4, 11, true), + (12, 4, 12, true), + (13, 4, 13, true), + (14, 4, 14, true), + (15, 4, 15, true), -- 여기까지 자신의 모집글에 자동 신청 - (1, 6, true), - (1, 7, true), - (1, 8, false), - (1, 9, false), - (1, 10, false), - (1, 11, false), - (1, 12, false), - (1, 13, false), - (1, 14, false), - (1, 15, false), - (3, 2, true), - (4, 1, true); + (16, 1, 6, true), + (17, 1, 7, true), + (18, 1, 8, false), + (19, 1, 9, false), + (20, 1, 10, false), + (21, 1, 11, false), + (22, 1, 12, false), + (23, 1, 13, false), + (24, 1, 14, false), + (25, 1, 15, false), + (26, 3, 2, true), + (27, 4, 1, true), + (28, 4, 3, true); INSERT INTO comment_tb (id, parent_id, post_id, user_id, content) VALUES (1, null, 1, null, '삭제된 댓글입니다.'), @@ -786,45 +787,45 @@ VALUES (1, null, 1, null, '삭제된 댓글입니다.'), (8, 7, 1, 1, '네 있습니다'); -- 해당 post에 신청 수락되어야함 / 모집완료(is_close)되고 start가 지난 post에만 score 등록 / -INSERT INTO score_tb (user_id, post_id, score_num) -VALUES (1, 7, 100), - (1, 1, 150); +INSERT INTO score_tb (id, user_id, post_id, score_num, result_image_url) +VALUES (1, 1, 7, 100, null), + (2, 1, 1, 150, 'https://kakao.com'); -- 해당 post에 신청 수락되어야함 / 모집완료(is_close)되고 start가 지난 post에만 score 등록 / INSERT INTO user_rate_tb(applicant_id, user_id, star_count) VALUES (1, 4, 5), (17, 3, 1); -INSERT INTO message_tb(user_id, opponent_user_id, content, is_receive, is_read) -VALUES (1, 3, '1번이 3번에게 보낸 쪽지1', false, true), - (3, 1, '1번이 3번에게 보낸 쪽지1', true, true), - (1, 3, '1번이 3번에게 보낸 쪽지2', false, true), - (3, 1, '1번이 3번에게 보낸 쪽지2', true, true), - (1, 3, '1번이 3번에게 보낸 쪽지3', false, true), - (3, 1, '1번이 3번에게 보낸 쪽지3', true, true), - (1, 3, '1번이 3번에게 보낸 쪽지4', false, true), - (3, 1, '1번이 3번에게 보낸 쪽지4', true, true), - (1, 3, '1번이 3번에게 보낸 쪽지5', false, true), - (3, 1, '1번이 3번에게 보낸 쪽지5', true, true), +INSERT INTO message_tb(id, user_id, opponent_user_id, content, is_receive, is_read) +VALUES (1, 1, 3, '1번이 3번에게 보낸 쪽지1', false, true), + (2, 3, 1, '1번이 3번에게 보낸 쪽지1', true, true), + (3, 1, 3, '1번이 3번에게 보낸 쪽지2', false, true), + (4, 3, 1, '1번이 3번에게 보낸 쪽지2', true, true), + (5, 1, 3, '1번이 3번에게 보낸 쪽지3', false, true), + (6, 3, 1, '1번이 3번에게 보낸 쪽지3', true, true), + (7, 1, 3, '1번이 3번에게 보낸 쪽지4', false, true), + (8, 3, 1, '1번이 3번에게 보낸 쪽지4', true, true), + (9, 1, 3, '1번이 3번에게 보낸 쪽지5', false, true), + (10, 3, 1, '1번이 3번에게 보낸 쪽지5', true, true), - (3, 1, '3번이 1번에게 보낸 쪽지1', false, true), - (1, 3, '3번이 1번에게 보낸 쪽지1', true, false), - (3, 1, '3번이 1번에게 보낸 쪽지2', false, true), - (1, 3, '3번이 1번에게 보낸 쪽지2', true, false), - (3, 1, '3번이 1번에게 보낸 쪽지3', false, true), - (1, 3, '3번이 1번에게 보낸 쪽지3', true, false), - (3, 1, '3번이 1번에게 보낸 쪽지4', false, true), - (1, 3, '3번이 1번에게 보낸 쪽지4', true, false), - (3, 1, '3번이 1번에게 보낸 쪽지5', false, true), - (1, 3, '3번이 1번에게 보낸 쪽지5', true, false), + (11, 3, 1, '3번이 1번에게 보낸 쪽지1', false, true), + (12, 1, 3, '3번이 1번에게 보낸 쪽지1', true, false), + (13, 3, 1, '3번이 1번에게 보낸 쪽지2', false, true), + (14, 1, 3, '3번이 1번에게 보낸 쪽지2', true, false), + (15, 3, 1, '3번이 1번에게 보낸 쪽지3', false, true), + (16, 1, 3, '3번이 1번에게 보낸 쪽지3', true, false), + (17, 3, 1, '3번이 1번에게 보낸 쪽지4', false, true), + (18, 1, 3, '3번이 1번에게 보낸 쪽지4', true, false), + (19, 3, 1, '3번이 1번에게 보낸 쪽지5', false, true), + (20, 1, 3, '3번이 1번에게 보낸 쪽지5', true, false), - (4, 3, '4번이 3번에게 보낸 쪽지1', false, true), - (3, 4, '4번이 3번에게 보낸 쪽지1', true, false), - (4, 3, '4번이 3번에게 보낸 쪽지2', false, true), - (3, 4, '4번이 3번에게 보낸 쪽지2', true, false), - (4, 3, '4번이 3번에게 보낸 쪽지3', false, true), - (3, 4, '4번이 3번에게 보낸 쪽지3', true, false), - (4, 3, '4번이 3번에게 보낸 쪽지4', false, true), - (3, 4, '4번이 3번에게 보낸 쪽지4', true, false), - (4, 3, '4번이 3번에게 보낸 쪽지5', false, true), - (3, 4, '4번이 3번에게 보낸 쪽지5', true, false); \ No newline at end of file + (21, 4, 3, '4번이 3번에게 보낸 쪽지1', false, true), + (22, 3, 4, '4번이 3번에게 보낸 쪽지1', true, false), + (23, 4, 3, '4번이 3번에게 보낸 쪽지2', false, true), + (24, 3, 4, '4번이 3번에게 보낸 쪽지2', true, false), + (25, 4, 3, '4번이 3번에게 보낸 쪽지3', false, true), + (26, 3, 4, '4번이 3번에게 보낸 쪽지3', true, false), + (27, 4, 3, '4번이 3번에게 보낸 쪽지4', false, true), + (28, 3, 4, '4번이 3번에게 보낸 쪽지4', true, false), + (29, 4, 3, '4번이 3번에게 보낸 쪽지5', false, true), + (30, 3, 4, '4번이 3번에게 보낸 쪽지5', true, false); \ No newline at end of file diff --git a/src/test/java/com/bungaebowling/server/_core/commons/ApiTag.java b/src/test/java/com/bungaebowling/server/_core/commons/ApiTag.java index d2f3a520..c7549f91 100644 --- a/src/test/java/com/bungaebowling/server/_core/commons/ApiTag.java +++ b/src/test/java/com/bungaebowling/server/_core/commons/ApiTag.java @@ -1,12 +1,12 @@ package com.bungaebowling.server._core.commons; public enum ApiTag { - AUTHORIZATION("회원가입 로그인 인증"), + AUTHORIZATION("회원가입, 로그인, 인증"), CITY("행정 구역"), POST("모집글"), - APPLICANT("신청"), + APPLICANT("신청, 별점"), COMMENT("댓글"), - USER("개인 프로필/정보"), + USER("개인 프로필, 정보"), RECORD("참여 기록"), SCORE("볼링 점수(스코어)"), MESSAGE("쪽지"); diff --git a/src/test/java/com/bungaebowling/server/applicant/controller/ApplicantControllerTest.java b/src/test/java/com/bungaebowling/server/applicant/controller/ApplicantControllerTest.java new file mode 100644 index 00000000..f951decc --- /dev/null +++ b/src/test/java/com/bungaebowling/server/applicant/controller/ApplicantControllerTest.java @@ -0,0 +1,432 @@ +package com.bungaebowling.server.applicant.controller; + +import com.bungaebowling.server.ControllerTestConfig; +import com.bungaebowling.server._core.commons.ApiTag; +import com.bungaebowling.server._core.commons.GeneralApiResponseSchema; +import com.bungaebowling.server._core.commons.GeneralParameters; +import com.bungaebowling.server._core.security.JwtProvider; +import com.bungaebowling.server.applicant.dto.ApplicantRequest; +import com.bungaebowling.server.user.Role; +import com.bungaebowling.server.user.User; +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.Schema; +import com.epages.restdocs.apispec.SimpleType; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.context.WebApplicationContext; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.hamcrest.Matchers.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@ActiveProfiles(value = {"test"}) +@Sql(value = "classpath:test_db/teardown.sql", config = @SqlConfig(encoding = "UTF-8")) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +class ApplicantControllerTest extends ControllerTestConfig { + + @Autowired + public ApplicantControllerTest(WebApplicationContext context, ObjectMapper om) { + super(context, om); + } + + @Test + @DisplayName("신청자 목록 조회 테스트") + void getApplicants() throws Exception { + // given + Long postId = 1L; + int size = 2; + int key = 30; + + var userId = 1L; // 김볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/posts/{postId}/applicants", postId) + .param("key", Integer.toString(key)) + .param("size", Integer.toString(size)) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.nextCursorRequest").exists(), + jsonPath("$.response.applicantNumber").isNumber(), + jsonPath("$.response.applicants[0].id").isNumber(), + jsonPath("$.response.applicants[0].user.id").isNumber(), + jsonPath("$.response.applicants[0].user.name").exists(), + jsonPath("$.response.applicants[0].user.profileImage").hasJsonPath(), + jsonPath("$.response.applicants[0].user.rating").isNumber(), + jsonPath("$.response.applicants[0].status").isBoolean(), + jsonPath("$.response.applicants[0].id").value(lessThan(key)), + jsonPath("$.response.applicants").value(hasSize(lessThanOrEqualTo(size))) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[applicant] getApplicants", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("신청자 목록 조회") + .description(""" + 모집글의 신청자 목록을 조회합니다. + """) + .tag(ApiTag.APPLICANT.getTagName()) + .pathParameters(parameterWithName("postId").description("조회할 모집글 id")) + .queryParameters( + GeneralParameters.CURSOR_KEY.getParameterDescriptorWithType(), + GeneralParameters.SIZE.getParameterDescriptorWithType() + ) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .responseSchema(Schema.schema("신청자 목록 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.NEXT_CURSOR.getResponseDescriptor().and( + fieldWithPath("response.applicantNumber").description("총 신청자의 수"), + fieldWithPath("response.applicants[].id").description("신청의 ID(PK)"), + fieldWithPath("response.applicants[].user.id").description("신청자의 ID(PK)"), + fieldWithPath("response.applicants[].user.name").description("신청자의 닉네임"), + fieldWithPath("response.applicants[].user.profileImage").optional().type(SimpleType.STRING).description("신청자의 프로필 이미지 경로"), + fieldWithPath("response.applicants[].user.rating").description("신청자의 별점"), + fieldWithPath("response.applicants[].status").description("신청 수락 여부 | true: 수락, false: 수락 대기") + ) + ) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집글에 대한 신청 테스트") + void create() throws Exception { + // given + Long postId = 2L; + + var userId = 4L; // 박볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .post("/api/posts/{postId}/applicants", postId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[applicant] create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글에 대한 신청") + .description(""" + 마감되지 않은 모집글에 신청을 합니다. + """) + .tag(ApiTag.APPLICANT.getTagName()) + .pathParameters(parameterWithName("postId").description("신청할 모집글 id")) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집자의 신청 수락 테스트") + void accept() throws Exception { + // given + Long postId = 8L; + + Long applicantId = 18L; + + var userId = 3L; // 이볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + var requestDto = new ApplicantRequest.UpdateDto(true); + String requestBody = om.writeValueAsString(requestDto); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .put("/api/posts/{postId}/applicants/{applicantId}", postId, applicantId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[applicant] accept", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집자의 신청 수락") + .description(""" + 모집자가 신청을 승낙 할 수 있습니다. + + status를 수정 가능합니다. true: 승낙 / false: 승낙 대기 + + 거절은 DELETE 요청으로 신청을 완전히 삭제 해주시길 바랍니다. + """) + .tag(ApiTag.APPLICANT.getTagName()) + .pathParameters( + parameterWithName("postId").description("모집글 id"), + parameterWithName("applicantId").description("수락할 신청의 Id") + ) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("모집자의 신청 수락 요청 DTO")) + .requestFields(fieldWithPath("status").type(SimpleType.BOOLEAN).description("변경하고자 하는 신청의 상태 | true: 승낙, false: 승낙 대기")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집자의 신청 거절 테스트") + void reject() throws Exception { + // given + Long postId = 8L; + + Long applicantId = 18L; + + var userId = 3L; // 이볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .delete("/api/posts/{postId}/applicants/{applicantId}", postId, applicantId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[applicant] reject", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집자의 신청 거절") + .description(""" + 모집자가 신청을 거절 합니다. + """) + .tag(ApiTag.APPLICANT.getTagName()) + .pathParameters( + parameterWithName("postId").description("모집글 id"), + parameterWithName("applicantId").description("거절할 신청의 Id") + ) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("자신의 신청 상태 조회 테스트") + void checkStatus() throws Exception { + // given + Long postId = 1L; + + var userId = 4L; // 박볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/posts/{postId}/applicants/check-status", postId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.applicantId").isNumber(), + jsonPath("$.response.isApplied").isBoolean(), + jsonPath("$.response.isAccepted").isBoolean() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[applicant] checkStatus", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("자신의 신청 상태 조회") + .description(""" + 해당 모집글에 대한 자신의 신청 상태를 확인 합니다. + """) + .tag(ApiTag.APPLICANT.getTagName()) + .pathParameters(parameterWithName("postId").description("조회할 모집글 id")) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .responseSchema(Schema.schema("자신의 신청 상태 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.SUCCESS.getResponseDescriptor().and( + fieldWithPath("response.applicantId").optional().type(SimpleType.NUMBER).description("신청의 ID(PK)"), + fieldWithPath("response.isApplied").description("신청 상태(true: 신청됨 / false: 신청되지 않음"), + fieldWithPath("response.isAccepted").description("승낙 상태(true: 승낙됨 / false: 승낙되지 않음") + ) + ) + .build() + ) + ) + ); + } + + @Test + @DisplayName("참여자에게 별점 등록 테스트") + void rateUser() throws Exception { + // given + Long postId = 3L; + + Long applicantId = 28L; + + var userId = 4L; // 박볼링 + + var targetId = 1L; + + var rating = 4; + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + var requestDto = new ApplicantRequest.RateDto(targetId, rating); + String requestBody = om.writeValueAsString(requestDto); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .post("/api/posts/{postId}/applicants/{applicantId}/rating", postId, applicantId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[applicant] rateUser", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("참여자에게 별점 등록") + .description(""" + 같은 모집글에 참여한 사람들에게 별점을 등록할 수 있습니다. + + 별점은 모집완료(모집글의 is_close가 true) 후 게임 시작 시간(start_time)이 지나야 등록 가능합니다. + """) + .tag(ApiTag.APPLICANT.getTagName()) + .pathParameters( + parameterWithName("postId").description("모집글 id"), + parameterWithName("applicantId").description("자신의 신청 id") + ) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("참여자에게 별점 등록 요청 DTO")) + .requestFields( + fieldWithPath("targetId").type(SimpleType.NUMBER).description("평가 대상 사용자의 id(PK)"), + fieldWithPath("rating").type(SimpleType.NUMBER).description("별점 | 1 ~ 5 범위 가능") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/bungaebowling/server/comment/controller/CommentControllerTest.java b/src/test/java/com/bungaebowling/server/comment/controller/CommentControllerTest.java index c5d9950f..05da518d 100644 --- a/src/test/java/com/bungaebowling/server/comment/controller/CommentControllerTest.java +++ b/src/test/java/com/bungaebowling/server/comment/controller/CommentControllerTest.java @@ -174,6 +174,7 @@ void getCommentsWithPage() throws Exception { GeneralParameters.CURSOR_KEY.getParameterDescriptorWithType(), GeneralParameters.SIZE.getParameterDescriptorWithType() ) + .responseSchema(Schema.schema("댓글 조회 응답 DTO")) .build() ) ) diff --git a/src/test/java/com/bungaebowling/server/message/controller/MessageControllerTest.java b/src/test/java/com/bungaebowling/server/message/controller/MessageControllerTest.java new file mode 100644 index 00000000..91f9537d --- /dev/null +++ b/src/test/java/com/bungaebowling/server/message/controller/MessageControllerTest.java @@ -0,0 +1,351 @@ +package com.bungaebowling.server.message.controller; + +import com.bungaebowling.server.ControllerTestConfig; +import com.bungaebowling.server._core.commons.ApiTag; +import com.bungaebowling.server._core.commons.GeneralApiResponseSchema; +import com.bungaebowling.server._core.commons.GeneralParameters; +import com.bungaebowling.server._core.security.JwtProvider; +import com.bungaebowling.server.message.dto.MessageRequest; +import com.bungaebowling.server.user.Role; +import com.bungaebowling.server.user.User; +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.Schema; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.context.WebApplicationContext; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@ActiveProfiles(value = {"test"}) +@Sql(value = "classpath:test_db/teardown.sql", config = @SqlConfig(encoding = "UTF-8")) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +public class MessageControllerTest extends ControllerTestConfig { + + @Autowired + public MessageControllerTest(WebApplicationContext context, ObjectMapper om) { + super(context, om); + } + + + @Test + @DisplayName("대화방(쪽지) 목록 조회 테스트") + void getOpponents() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + + int size = 20; + int key = 30; + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/messages/opponents") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .param("key", Integer.toString(key)) + .param("size", Integer.toString(size)) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.messages[0].opponentUserId").isNumber(), + jsonPath("$.response.messages[0].opponentUserName").exists(), + jsonPath("$.response.messages[0].recentMessage").exists(), + jsonPath("$.response.messages[0].recentTime").exists(), + jsonPath("$.response.messages[0].countNew").isNumber() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[message] getOpponents", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("대화방(쪽지) 목록 조회") + .description(""" + 대화 목록를 조회합니다. + """) + .tag(ApiTag.MESSAGE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .queryParameters( + GeneralParameters.CURSOR_KEY.getParameterDescriptorWithType(), + GeneralParameters.SIZE.getParameterDescriptorWithType() + ) + .responseSchema(Schema.schema("대화방 목록 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.NEXT_CURSOR.getResponseDescriptor().and( + fieldWithPath("response.messages[].opponentUserId").description("쪽지 상대 유저 ID"), + fieldWithPath("response.messages[].opponentUserName").description("쪽지 상대 유저 이름"), + fieldWithPath("response.messages[].opponentUserProfileImage").description("상대 프로필 사진 경로 | 사진이 없을 경우 null"), + fieldWithPath("response.messages[].recentMessage").description("쪽지 상대와의 가장 최근 메시지 내용"), + fieldWithPath("response.messages[].recentTime").description("쪽지 상대와의 가장 최근 송수신 시각"), + fieldWithPath("response.messages[].countNew").description("안 앍은 메시지의 수") + ) + ) + .build() + ) + ) + ); + } + + @Test + @DisplayName("일대일 대화방 쪽지 조회 테스트") + void getMessagesAndUpdateToRead() throws Exception { + // given + Long userId = 1L; + Long opponentId = 3L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + + int size = 20; + int key = 30; + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/messages/opponents/{opponentId}", opponentId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .param("key", Integer.toString(key)) + .param("size", Integer.toString(size)) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.opponentUserName").exists(), + jsonPath("$.response.messages[0].id").exists(), + jsonPath("$.response.messages[0].content").exists(), + jsonPath("$.response.messages[0].time").exists(), + jsonPath("$.response.messages[0].isRead").isBoolean(), + jsonPath("$.response.messages[0].isReceive").isBoolean() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[message] getMessagesAndUpdateToRead", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("일대일 대화방 쪽지 조회") + .description(""" + 상대 유저와의 쪽지 목록을 조회합니다. + """) + .tag(ApiTag.MESSAGE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .queryParameters( + GeneralParameters.CURSOR_KEY.getParameterDescriptorWithType(), + GeneralParameters.SIZE.getParameterDescriptorWithType() + ) + .pathParameters( + parameterWithName("opponentId").description("쪽지를 조회할 상대 유저의 Id") + ) + .responseSchema(Schema.schema("일대일 대화방 쪽지 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.NEXT_CURSOR.getResponseDescriptor().and( + fieldWithPath("response.opponentUserName").description("쪽지 상대 유저 이름"), + fieldWithPath("response.opponentUserProfileImage").description("상대 프로필 사진 경로 | 사진이 없을 경우 null"), + fieldWithPath("response.messages[].id").description("쪽지 ID"), + fieldWithPath("response.messages[].content").description("쪽지 내용"), + fieldWithPath("response.messages[].time").description("쪽지 송신시간"), + fieldWithPath("response.messages[].isRead").description("쪽지대상이 읽었는지"), + fieldWithPath("response.messages[].isReceive").description("내가 받은 쪽지인지") + ) + ) + .build() + ) + ) + ); + } + + @Test + @DisplayName("쪽지 보내기 테스트") + void sendMessage() throws Exception { + // given + Long userId = 1L; + Long opponentUserId = 3L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + MessageRequest.SendMessageDto requestDto = new MessageRequest.SendMessageDto("쪽지보내기 테스트"); + String requestBody = om.writeValueAsString(requestDto); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .post("/api/messages/opponents/{opponentId}", opponentUserId) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[message] sendMessage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("쪽지 보내기") + .description(""" + 쪽지를 보냅니다. + """) + .tag(ApiTag.MESSAGE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("쪽지 보내기 요청 DTO")) + .requestFields(fieldWithPath("content").description("송신할 쪽지 내용")) + .pathParameters( + parameterWithName("opponentId").description("쪽지를 수신할 유저의 Id") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("쪽지함 삭제 테스트") + void deleteMessagesByOpponentId() throws Exception { + // given + Long userId = 1L; + Long opponentId = 3L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .delete("/api/messages/opponents/{opponentId}", opponentId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[message] deleteMessagesByOpponentId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("쪽지함 삭제") + .description(""" + 해당유저와의 모든 쪽지를 삭제합니다.(쪽지함을 삭제합니다.) + """) + .tag(ApiTag.MESSAGE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters( + parameterWithName("opponentId").description("쪽지를 삭제할 대화 상대의 Id") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("쪽지 개별 삭제") + void deleteMessageById() throws Exception { + // given + Long userId = 1L; + Long messageId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .delete("/api/messages/{messageId}", messageId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[message] deleteMessageById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("쪽지 개별 삭제") + .description(""" + 쪽지를 삭제합니다. + """) + .tag(ApiTag.MESSAGE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters( + parameterWithName("messageId").description("삭제할 쪽지 id") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + +} diff --git a/src/test/java/com/bungaebowling/server/message/MessageRepositoryTest.java b/src/test/java/com/bungaebowling/server/message/repository/MessageRepositoryTest.java similarity index 98% rename from src/test/java/com/bungaebowling/server/message/MessageRepositoryTest.java rename to src/test/java/com/bungaebowling/server/message/repository/MessageRepositoryTest.java index f5d1fe12..36937f27 100644 --- a/src/test/java/com/bungaebowling/server/message/MessageRepositoryTest.java +++ b/src/test/java/com/bungaebowling/server/message/repository/MessageRepositoryTest.java @@ -1,6 +1,6 @@ -package com.bungaebowling.server.message; +package com.bungaebowling.server.message.repository; -import com.bungaebowling.server.message.repository.MessageRepository; +import com.bungaebowling.server.message.Message; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bungaebowling/server/post/controller/PostControllerTest.java b/src/test/java/com/bungaebowling/server/post/controller/PostControllerTest.java new file mode 100644 index 00000000..d98eaab2 --- /dev/null +++ b/src/test/java/com/bungaebowling/server/post/controller/PostControllerTest.java @@ -0,0 +1,535 @@ +package com.bungaebowling.server.post.controller; + +import com.bungaebowling.server.ControllerTestConfig; +import com.bungaebowling.server._core.commons.ApiTag; +import com.bungaebowling.server._core.commons.GeneralApiResponseSchema; +import com.bungaebowling.server._core.commons.GeneralParameters; +import com.bungaebowling.server._core.security.JwtProvider; +import com.bungaebowling.server.post.dto.PostRequest; +import com.bungaebowling.server.user.Role; +import com.bungaebowling.server.user.User; +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.Schema; +import com.epages.restdocs.apispec.SimpleType; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.joda.time.DateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.context.WebApplicationContext; + +import java.time.LocalDateTime; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@ActiveProfiles(value = {"test"}) +@Sql(value = "classpath:test_db/teardown.sql", config = @SqlConfig(encoding = "UTF-8")) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +class PostControllerTest extends ControllerTestConfig { + + @Autowired + public PostControllerTest(WebApplicationContext context, ObjectMapper om) { + super(context, om); + } + + @Test + @DisplayName("모집글 목록 조회") + void getPosts() throws Exception { + //given + int size = 20; + int key = 30; + Long cityId = 1L; + Long countryId = 1L; + Long districtId = 1L; + Boolean all = Boolean.TRUE; + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/posts") + .param("key", Integer.toString(key)) + .param("size", Integer.toString(size)) + .param("cityId", Long.toString(cityId)) + .param("countryId", Long.toString(countryId)) + .param("districtId", Long.toString(districtId)) + .param("all", Boolean.toString(all)) + + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.nextCursorRequest.key").isNumber(), + jsonPath("$.response.nextCursorRequest.size").isNumber(), + jsonPath("$.response.posts[0].id").isNumber(), + jsonPath("$.response.posts[0].title").exists(), + jsonPath("$.response.posts[0].dueTime").exists(), + jsonPath("$.response.posts[0].districtName").exists(), + jsonPath("$.response.posts[0].startTime").exists(), + jsonPath("$.response.posts[0].userName").exists(), + jsonPath("$.response.posts[0].currentNumber").isNumber(), + jsonPath("$.response.posts[0].isClose").isBoolean() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] getPosts", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글 목록 조회") + .description(""" + 모집글 목록를 조회합니다. + """) + .tag(ApiTag.POST.getTagName()) + .queryParameters( + GeneralParameters.CURSOR_KEY.getParameterDescriptorWithType(), + GeneralParameters.SIZE.getParameterDescriptorWithType(), + parameterWithName("cityId").optional().type(SimpleType.NUMBER).description("시/도 ID (넘겨주지 않을 시 설정 시/도 설정 안 한 것)"), + parameterWithName("countryId").optional().type(SimpleType.NUMBER).description("시/군/구 ID (넘겨주지 않을 시 설정 시/군/구 설정 안 한 것)"), + parameterWithName("districtId").optional().type(SimpleType.NUMBER).description("읍/면/동 ID (넘겨주지 않을 시 설정 읍/면/동 설정 안 한 것)"), + parameterWithName("all").type(SimpleType.BOOLEAN).defaultValue(true).optional().description("전체 보기/모집 중 선택") + + ) + .responseSchema(Schema.schema("모집글 목록 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.NEXT_CURSOR.getResponseDescriptor().and( + fieldWithPath("response.posts[].id").description("조회된 모집글 ID"), + fieldWithPath("response.posts[].title").description("조회된 모집글 제목 "), + fieldWithPath("response.posts[].dueTime").description("조회된 모집글 모집 마감기한"), + fieldWithPath("response.posts[].districtName").description("조회된 모집글 행정구역"), + fieldWithPath("response.posts[].startTime").description("조회된 모집글 게임 예정 일시"), + fieldWithPath("response.posts[].userName").description("조회된 모집글 작성자 이름 "), + fieldWithPath("response.posts[].profileImage").optional().type(SimpleType.STRING).description("조회된 모집글 작성자 프로필 사진 경로 | 사진이 없을 경우 null"), + fieldWithPath("response.posts[].currentNumber").description("조회된 모집글 참석 인원"), + fieldWithPath("response.posts[].isClose").description("모집글 마감 여부") + + ) + ) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집글 상세 조회") + void getPost() throws Exception { + // given + Long postId = 1L; + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/posts/{postId}", postId) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.post.id").isNumber(), + jsonPath("$.response.post.title").exists(), + jsonPath("$.response.post.userId").isNumber(), + jsonPath("$.response.post.userName").exists(), + jsonPath("$.response.post.districtName").exists(), + jsonPath("$.response.post.currentNumber").isNumber(), + jsonPath("$.response.post.content").exists(), + jsonPath("$.response.post.startTime").exists(), + jsonPath("$.response.post.dueTime").exists(), + jsonPath("$.response.post.viewCount").isNumber(), + jsonPath("$.response.post.createdAt").exists(), + jsonPath("$.response.post.editedAt").exists(), + jsonPath("$.response.post.isClose").isBoolean() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] getPost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글 상세 조회") + .description(""" + 모집글의 상세 정보를 조회합니다. + """) + .tag(ApiTag.POST.getTagName()) + .pathParameters( + parameterWithName("postId").type(SimpleType.NUMBER).description("조회할 모집글 ID") + ) + .responseSchema(Schema.schema("모집글 상세 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.SUCCESS.getResponseDescriptor().and( + fieldWithPath("response.post.id").description("모집글 ID"), + fieldWithPath("response.post.title").description("모집글 제목"), + fieldWithPath("response.post.userId").description("모집글 작성자 ID"), + fieldWithPath("response.post.userName").description("모집글 작성자 이름"), + fieldWithPath("response.post.profileImage").optional().type(SimpleType.NUMBER).description("조회된 모집글 작성자 프로필 사진 경로 | 사진이 없을 경우 null"), + fieldWithPath("response.post.districtName").description("모집글 행정 구역"), + fieldWithPath("response.post.currentNumber").description("현재 모집 확정 인원 수"), + fieldWithPath("response.post.content").description("모집글 내용"), + fieldWithPath("response.post.startTime").description("게임 예정 일시"), + fieldWithPath("response.post.dueTime").description("모집 마감기한"), + fieldWithPath("response.post.viewCount").description("조회 수"), + fieldWithPath("response.post.createdAt").description("모집글 생성 시간"), + fieldWithPath("response.post.editedAt").description("모집글 수정 시간 "), + fieldWithPath("response.post.isClose").description("모집글 마감 여부") + )) + .build() + ) + ) + ); + + } + + @Test + @DisplayName("참여 기록 조회") + void getUserParticipationRecords() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + int size = 20; + int key = 30; + Long cityId = 1L; + String condition = "all"; + String status = "all"; + DateTime start = DateTime.now().minusMonths(3); + DateTime end = DateTime.now(); + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/posts/users/{userId}/participation-records", userId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .param("key", Integer.toString(key)) + .param("size", Integer.toString(size)) + .param("condition", condition) + .param("status", status) + .param("cityId", Long.toString(cityId)) + .param("start", start.toString("yyyy-MM-dd")) + .param("end", end.toString("yyyy-MM-dd")) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.nextCursorRequest.key").isNumber(), + jsonPath("$.response.nextCursorRequest.size").isNumber(), + jsonPath("$.response.posts[0].id").isNumber(), + jsonPath("$.response.posts[0].applicantId").isNumber(), + jsonPath("$.response.posts[0].title").exists(), + jsonPath("$.response.posts[0].dueTime").exists(), + jsonPath("$.response.posts[0].districtName").exists(), + jsonPath("$.response.posts[0].startTime").exists(), + jsonPath("$.response.posts[0].currentNumber").isNumber(), + jsonPath("$.response.posts[0].isClose").isBoolean(), + jsonPath("$.response.posts[0].scores").exists(), + jsonPath("$.response.posts[0].members[0].id").exists() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] getUserParticipationRecords", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("참여 기록 조회") + .description(""" + 참여 기록을 조회합니다. + """) + .tag(ApiTag.POST.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters( + parameterWithName("userId").type(SimpleType.NUMBER).description("조회할 유저의 ID") + ) + .queryParameters( + GeneralParameters.CURSOR_KEY.getParameterDescriptorWithType(), + GeneralParameters.SIZE.getParameterDescriptorWithType(), + parameterWithName("condition").optional().type(SimpleType.STRING).defaultValue("all").description("모집글 유형 (종류 : 전체 보기 - all, 작성한 글 - created, 참여한 글 - participated)"), + parameterWithName("status").optional().type(SimpleType.STRING).defaultValue("all").description("모집글 상태 (종류 : 전체 보기 - all, 모집중 - open, 모집 완료 - closed)"), + parameterWithName("cityId").optional().type(SimpleType.NUMBER).description("모집 장소 (시/군/구)"), + parameterWithName("start").optional().type(SimpleType.STRING).defaultValue(start.toString("yyyy-MM-dd")).description("조회 시작일자, 기본 값: 3개월 전"), + parameterWithName("end").optional().type(SimpleType.STRING).defaultValue(end.toString("yyyy-MM-dd")).description("조회 종료일자, 기본 값: 현재 날짜") + ) + .responseSchema(Schema.schema("참여 기록 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.NEXT_CURSOR.getResponseDescriptor().and( + fieldWithPath("response.posts[].id").description("모집글 ID"), + fieldWithPath("response.posts[].applicantId").description("조회하고자 하는 유저의 ID가 모집글에 신청한 ID"), + fieldWithPath("response.posts[].title").description("모집글 제목 "), + fieldWithPath("response.posts[].dueTime").description("게임 마감 일시 "), + fieldWithPath("response.posts[].districtName").description("지역"), + fieldWithPath("response.posts[].startTime").description("게임 예정 일시 "), + fieldWithPath("response.posts[].currentNumber").description("현재 모집 확정 인원 수"), + fieldWithPath("response.posts[].isClose").description("모집글 마감 여부"), + fieldWithPath("response.posts[].scores").description("스코어 정보"), + fieldWithPath("response.posts[].scores[].id").optional().type(SimpleType.NUMBER).description("스코어 ID"), + fieldWithPath("response.posts[].scores[].score").optional().type(SimpleType.NUMBER).description("등록된 사용자 스코어"), + fieldWithPath("response.posts[].scores[].scoreImage").optional().type(SimpleType.STRING).description("등록된 사용자 스코어 사진 경로 | 사진이 없을 경우 null"), + fieldWithPath("response.posts[].members").description("모집 멤버 정보"), + fieldWithPath("response.posts[].members[].id").optional().type(SimpleType.NUMBER).description("모집 멤버 ID"), + fieldWithPath("response.posts[].members[].name").optional().type(SimpleType.STRING).description("모집 멤버 이름"), + fieldWithPath("response.posts[].members[].profileImage").optional().type(SimpleType.STRING).description("모집 멤버 프로필 사진 경로 | 사진이 없을 경우 null"), + fieldWithPath("response.posts[].members[].isRated").optional().type(SimpleType.BOOLEAN).description("모집 멤버 별점 입력 여부") + )) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집글 등록") + void createPost() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + PostRequest.CreatePostDto requestDto = new PostRequest.CreatePostDto("테스트", LocalDateTime.now().plusDays(2), LocalDateTime.now().plusDays(1), "테스트", 1L); + String requestBody = om.writeValueAsString(requestDto); + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .post("/api/posts") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200), + jsonPath("$.response.id").isNumber() + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] createPost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글 등록") + .description(""" + 모집글을 등록합니다. + """) + .tag(ApiTag.POST.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("모집글 등록 요청 DTO")) + .requestFields( + fieldWithPath("title").description("모집글 제목"), + fieldWithPath("districtId").description("모집글 행정구역 ID"), + fieldWithPath("startTime").description("게임 예정 일시"), + fieldWithPath("dueTime").description("모집 마감기한"), + fieldWithPath("content").description("모집글 내용") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.CREATED.getName())) + .responseFields(GeneralApiResponseSchema.CREATED.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집글 수정") + void updatePost() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + Long postId = 2L; + PostRequest.UpdatePostDto requestDto = new PostRequest.UpdatePostDto("테스트", LocalDateTime.now().plusDays(2), LocalDateTime.now().plusDays(1), "테스트"); + String requestBody = om.writeValueAsString(requestDto); + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .put("/api/posts/{postId}", postId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] updatePost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글 수정") + .description(""" + 모집글을 수정합니다. + + 모집마감된 모집글은 수정이 불가능합니다. + """) + .tag(ApiTag.POST.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("모집글 수정 요청 DTO")) + .requestFields( + fieldWithPath("title").description("모집글 제목"), + fieldWithPath("startTime").description("게임 예정 일시"), + fieldWithPath("dueTime").description("모집 마감기한"), + fieldWithPath("content").description("모집글 내용") + ) + .pathParameters(parameterWithName("postId").type(SimpleType.NUMBER).description("수정할 모집글의 ID")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집글 삭제") + void deletePost() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + Long postId = 2L; + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .delete("/api/posts/{postId}", postId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] deletePost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글 삭제") + .description(""" + 모집글을 삭제합니다. + + 모집마감된 모집글은 삭제가 불가능합니다. + """) + .tag(ApiTag.POST.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters(parameterWithName("postId").type(SimpleType.NUMBER).description("삭제할 모집글의 ID")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("모집글 마감") + void patchPost() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + Long postId = 1L; + Boolean isClose = true; + PostRequest.UpdatePostIsCloseDto requestDto = new PostRequest.UpdatePostIsCloseDto(isClose); + String requestBody = om.writeValueAsString(requestDto); + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .patch("/api/posts/{postId}", postId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[post] patchPost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("모집글 마감") + .description(""" + 모집글을 마감합니다. + """) + .tag(ApiTag.POST.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("모집글 마감 요청 DTO")) + .requestFields( + fieldWithPath("isClose").description("마감 여부") + ) + .pathParameters(parameterWithName("postId").type(SimpleType.NUMBER).description("마감할 모집글의 ID")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/bungaebowling/server/score/controller/ScoreControllerTest.java b/src/test/java/com/bungaebowling/server/score/controller/ScoreControllerTest.java new file mode 100644 index 00000000..12c851eb --- /dev/null +++ b/src/test/java/com/bungaebowling/server/score/controller/ScoreControllerTest.java @@ -0,0 +1,406 @@ +package com.bungaebowling.server.score.controller; + +import com.amazonaws.services.s3.AmazonS3; +import com.bungaebowling.server.ControllerTestConfig; +import com.bungaebowling.server._core.commons.ApiTag; +import com.bungaebowling.server._core.commons.GeneralApiResponseSchema; +import com.bungaebowling.server._core.security.JwtProvider; +import com.bungaebowling.server.user.Role; +import com.bungaebowling.server.user.User; +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.Schema; +import com.epages.restdocs.apispec.SimpleType; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockPart; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.web.context.WebApplicationContext; + +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@ActiveProfiles(value = {"test"}) +@Sql(value = "classpath:test_db/teardown.sql", config = @SqlConfig(encoding = "UTF-8")) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +class ScoreControllerTest extends ControllerTestConfig { + + @MockBean + private AmazonS3 amazonS3Client; + + @Autowired + public ScoreControllerTest(WebApplicationContext context, ObjectMapper om) { + super(context, om); + } + + @Test + @DisplayName("점수 조회 테스트") + void getScores() throws Exception { + // given + Long postId = 1L; + + Long userId = 1L; + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .get("/api/posts/{postId}/scores", postId) + .param("userId", Long.toString(userId)) + ); + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[score] getScores", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("점수 조회") + .description(""" + 모집글의 점수들을 조회합니다. + """ + ) + .tag(ApiTag.SCORE.getTagName()) + .pathParameters(parameterWithName("postId").description("모집글 id")) + .queryParameters(parameterWithName("userId").optional().type(SimpleType.NUMBER).description("점수를 확인할 사용자 id")) + .responseSchema(Schema.schema("점수 조회 응답 DTO")) + .responseFields( + GeneralApiResponseSchema.SUCCESS.getResponseDescriptor().and( + fieldWithPath("response.scores").description("점수 목록"), + fieldWithPath("response.scores[].id").description("점수의 ID(PK)"), + fieldWithPath("response.scores[].scoreNum").description("볼링 점수"), + fieldWithPath("response.scores[].scoreImage").optional().type(SimpleType.STRING).description("점수 첨부 이미지 경로") + ) + ) + .build() + ) + ) + ); + } + + @Test + @DisplayName("점수 등록 테스트") + void createScore() throws Exception { + // given + Long postId = 1L; + + var userId = 1L; // 김볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + int score = 153; + MockMultipartFile file = new MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, "mockImageData".getBytes()); + + String imageUrl = "https://kakao.com"; + + BDDMockito.given(amazonS3Client.putObject(Mockito.any())).willReturn(null); + BDDMockito.given(amazonS3Client.getUrl(Mockito.any(), Mockito.any())).willReturn(new URL(imageUrl)); + + // when + var builder = RestDocumentationRequestBuilders + .multipart("/api/posts/{postId}/scores", postId); + builder.with(new RequestPostProcessor() { + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + request.setMethod("POST"); + return request; + } + }); + ResultActions resultActions = mvc.perform( + builder + .file(file) + .part(new MockPart("score", Integer.toString(score).getBytes(StandardCharsets.UTF_8))) + .contentType(MediaType.MULTIPART_FORM_DATA) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[score] createScore", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("점수 등록") + .description(""" + 모집 완료되어 게임 플레이 한 이후(start_time 이후) 자신이 참여한 모집글에 점수 등록이 가능합니다. + + - 파일은 png, jpg, gif, jpeg만 업로드 가능합니다. + - 파일은 10MB의 크기 제한이 존재합니다. + + 현재 사용 플러그인이 multipart/form-data의 파라미터에 대한 문서화가 지원되지 않습니다. (try it out 불가능) + + | Part | Type | Description | + |-------|--------|------------------------------| + | score | number | 볼링 점수 (1~300) | + | image | Binary | 점수판 사진 등 첨부 이미지 파일 | + + """) + .tag(ApiTag.SCORE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters(parameterWithName("postId").type(SimpleType.NUMBER).description("모집글 id")) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("점수 수정 테스트") + void updateScore() throws Exception { + // given + Long postId = 1L; + + Long scoreId = 2L; + + var userId = 1L; // 김볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + int score = 153; + MockMultipartFile file = new MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, "mockImageData".getBytes()); + + String imageUrl = "https://kakao.com"; + + BDDMockito.given(amazonS3Client.putObject(Mockito.any())).willReturn(null); + BDDMockito.given(amazonS3Client.getUrl(Mockito.any(), Mockito.any())).willReturn(new URL(imageUrl)); + + // when + var builder = RestDocumentationRequestBuilders + .multipart("/api/posts/{postId}/scores/{scoreId}", postId, scoreId); + builder.with(new RequestPostProcessor() { + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + request.setMethod("PUT"); + return request; + } + }); + ResultActions resultActions = mvc.perform( + builder + .file(file) + .part(new MockPart("score", Integer.toString(score).getBytes(StandardCharsets.UTF_8))) + .contentType(MediaType.MULTIPART_FORM_DATA) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[score] updateScore", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("점수 수정") + .description(""" + 자신의 점수에 대한 정보를 수정 가능합니다. + + 요청에 포함된 image나 score에 대하여 기존 정보를 수정합니다. + + - 파일은 png, jpg, gif, jpeg만 업로드 가능합니다. + - 파일은 10MB의 크기 제한이 존재합니다. + + 현재 사용 플러그인이 multipart/form-data의 파라미터에 대한 문서화가 지원되지 않습니다. (try it out 불가능) + + 아래 파라미터 중 변경 할 요소만 포함하여 보내면 됩니다. + + | Part | Type | Description | + |-------|--------|------------------------------| + | score | number | 볼링 점수 (1~300) | + | image | Binary | 점수판 사진 등 첨부 이미지 파일 | + + """) + .tag(ApiTag.SCORE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters( + parameterWithName("postId").type(SimpleType.NUMBER).description("모집글 id"), + parameterWithName("scoreId").type(SimpleType.NUMBER).description("점수 id") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("점수 이미지 삭제 테스트") + void deleteScoreImage() throws Exception { + // given + Long postId = 1L; + + Long scoreId = 2L; + + var userId = 1L; // 김볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + BDDMockito.willAnswer(invocation -> { + return null; + }).given(amazonS3Client).deleteObject(Mockito.any()); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .delete("/api/posts/{postId}/scores/{scoreId}/image", postId, scoreId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[score] deleteScoreImage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("점수 이미지 삭제") + .description(""" + 점수는 유지한 채 이미지만 삭제합니다. + """) + .tag(ApiTag.SCORE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters( + parameterWithName("postId").type(SimpleType.NUMBER).description("모집글 id"), + parameterWithName("scoreId").type(SimpleType.NUMBER).description("점수 id") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("점수 삭제 테스트") + void deleteScore() throws Exception { + // given + Long postId = 1L; + + Long scoreId = 2L; + + var userId = 1L; // 김볼링 + + var accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); + + BDDMockito.willAnswer(invocation -> { + return null; + }).given(amazonS3Client).deleteObject(Mockito.any()); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .delete("/api/posts/{postId}/scores/{scoreId}", postId, scoreId) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + + // then + var responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[score] deleteScore", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("점수 삭제") + .description(""" + 점수 행을 완전히 삭제합니다. + """) + .tag(ApiTag.SCORE.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .pathParameters( + parameterWithName("postId").type(SimpleType.NUMBER).description("모집글 id"), + parameterWithName("scoreId").type(SimpleType.NUMBER).description("점수 id") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/bungaebowling/server/user/controller/UserControllerTest.java b/src/test/java/com/bungaebowling/server/user/controller/UserControllerTest.java index cec06173..d0c23ff0 100644 --- a/src/test/java/com/bungaebowling/server/user/controller/UserControllerTest.java +++ b/src/test/java/com/bungaebowling/server/user/controller/UserControllerTest.java @@ -1001,4 +1001,174 @@ void getUserRecords() throws Exception { ) ); } + + @Test + @DisplayName("비밀번호 변경") + void updatePassword() throws Exception { + // given + Long userId = 1L; + String accessToken = JwtProvider.createAccess( + User.builder() + .id(userId) + .role(Role.ROLE_USER) + .build() + ); // 김볼링 + UserRequest.UpdatePasswordDto requestDto = new UserRequest.UpdatePasswordDto("test12!@", "qwer1234!"); + String requestBody = om.writeValueAsString(requestDto); + + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .patch("/api/users/password") + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, accessToken) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[user] updatePassword", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("비밀번호 변경") + .description(""" + 비밀번호를 변경합니다. + """) + .tag(ApiTag.USER.getTagName()) + .requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("access token")) + .requestSchema(Schema.schema("비밀번호 변경 요청 DTO")) + .requestFields( + fieldWithPath("password").description("기존 비밀번호"), + fieldWithPath("newPassword").description("새로운 비밀번호") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("비밀번호 찾기 - 본인 인증 메일 발송") + void sendVerificationMailForPasswordReset() throws Exception { + // given + UserRequest.SendVerificationMailForPasswordResetDto requestDto = new UserRequest.SendVerificationMailForPasswordResetDto("test@test.com"); + String requestBody = om.writeValueAsString(requestDto); + + JavaMailSender javaMailSenderImpl = new JavaMailSenderImpl(); + + BDDMockito.given(javaMailSender.createMimeMessage()).willReturn(javaMailSenderImpl.createMimeMessage()); + BDDMockito.willAnswer(invocation -> { + return null; + }).given(javaMailSender).send(Mockito.any(MimeMessagePreparator.class)); + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .post("/api/password/email-verification") + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[user] sendVerificationMailForPasswordReset", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("비밀번호 찾기 - 본인 인증 메일 발송") + .description(""" + 계정 정보의 email로 인증 메일을 보냅니다. email에는 url이 삽입되어 보내집니다. + + (e.g.) https://bungaebowling.com/password/email-verification?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwicm9sZSI6IlJPTEVfVVNFUiIsInR5cGUiOiJlbWFpbC12ZXJpZmljYXRpb24iLCJleHAiOjE2OTU2MzU5MDh9.3AWusXvtgBiQN0GoegjKJw-fnaYSGVO1Ue0sSrtuWCVOQwzfIwh6KELN2NHOOXIO6MK-D11PndbtwcHetibZVQ + + 인증을 위해서 이 토큰 값을 그대로 /api/password/email-confirm의 데이터로 요청 보내주시길 바랍니다. + """) + .tag(ApiTag.AUTHORIZATION.getTagName()) + .requestSchema(Schema.schema("비밀번호 찾기 - 본인 인증 메일 발송 요청 DTO")) + .requestFields( + fieldWithPath("email").description("가입한 이메일") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } + + @Test + @DisplayName("비밀번호 찾기 - 본인 인증 메일 확인 및 임시 비밀번호 메일 발송") + void confirmEmailAndSendTempPassword() throws Exception { + // given + User user = User.builder() + .id(1L) + .build(); + String token = JwtProvider.createEmailVerificationForPassword(user); + UserRequest.ConfirmEmailAndSendTempPasswordDto requestDto = new UserRequest.ConfirmEmailAndSendTempPasswordDto(token); + String requestBody = om.writeValueAsString(requestDto); + + JavaMailSender javaMailSenderImpl = new JavaMailSenderImpl(); + + BDDMockito.given(javaMailSender.createMimeMessage()).willReturn(javaMailSenderImpl.createMimeMessage()); + BDDMockito.willAnswer(invocation -> { + return null; + }).given(javaMailSender).send(Mockito.any(MimeMessagePreparator.class)); + // when + ResultActions resultActions = mvc.perform( + RestDocumentationRequestBuilders + .post("/api/password/email-confirm") + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + // then + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + Object json = om.readValue(responseBody, Object.class); + System.out.println("[response]\n" + om.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.status").value(200) + ).andDo( + MockMvcRestDocumentationWrapper.document( + "[user] confirmEmailAndSendTempPassword", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .summary("비밀번호 찾기 - 본인 인증 메일 확인 및 임시 비밀번호 메일 발송") + .description(""" + 본인 인증 확인 후 기존 비밀번호를 삭제하고 임시 비밀번호를 생성합니다. + + 임시 비밀번호를 메일로 발송합니다. + """) + .tag(ApiTag.AUTHORIZATION.getTagName()) + .requestSchema(Schema.schema("비밀번호 찾기 - 본인 인증 메일 발송 요청 DTO")) + .requestFields( + fieldWithPath("token").description("메일로 발송된 링크에 첨부된 토큰") + ) + .responseSchema(Schema.schema(GeneralApiResponseSchema.SUCCESS.getName())) + .responseFields(GeneralApiResponseSchema.SUCCESS.getResponseDescriptor()) + .build() + ) + ) + ); + } } \ No newline at end of file