diff --git a/README.md b/README.md index 38a2046a..1fea4d75 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ # Kkeujeok_Backend +## 커밋 메시지 컨벤션 + +- Feat(#이슈 번호): 새로운 기능 추가 +- Fix(#이슈 번호): 버그 수정 +- Refactor(#이슈 번호): 코드 리팩토링 (기능 변경 없음) +- Docs(#이슈 번호): 문서 수정 +- Style(#이슈 번호): 코드 포맷팅, 세미콜론 누락 등 (기능 변경 없음) +- Test(#이슈 번호): 테스트 코드 추가 및 수정 +- Chore(#이슈 번호): 빌드 작업, 패키지 매니저 설정 + +## 코드 스타일 + +- 클래스 선언부 아래 필드가 오면 한 칸 띄우고 작성하고 그 이외의 경우에는 붙인다. +- 메서드 길이는 10줄을 넘지 않는다. +- 블록 들여쓰기는 1단계로 제한한다. +- 블록 아래 한 칸 띄우고 작성한다. +- else를 사용하지 않는다. +- stream 사용 시 stream 뒤에 줄바꿈을 한다. +- 필드에 어노테이션이 붙으면 한 칸 씩 띄어쓴다. + +## 메서드 명 컨벤션 + +- 생성: save +- 수정: update +- 삭제: delete +- 조회: find +- 매개변수로 넘어오는 값을 메서드 명에 포함시킨다. + +## 코드 리뷰 컨벤션 + +- 모든 PR은 최소 2명의 리뷰어가 승인한다. + +## 어노테이션 순서 + +- 길이 내림차순 diff --git a/build.gradle b/build.gradle index aa0f4d80..5fd267dc 100644 --- a/build.gradle +++ b/build.gradle @@ -28,12 +28,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' - + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '3.3.2' + implementation 'org.springframework.boot:spring-boot-starter-web-services' + // logback implementation 'com.github.napstr:logback-discord-appender:1.0.0' implementation 'com.github.maricn:logback-slack-appender:1.4.0' runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/docs/asciidoc/block.adoc b/src/docs/asciidoc/block.adoc new file mode 100644 index 00000000..85cd12fa --- /dev/null +++ b/src/docs/asciidoc/block.adoc @@ -0,0 +1,119 @@ +== 블록 API 문서 + +=== 블록 생성 API + +==== 요청 + +include::{snippets}/block/save/http-request.adoc[] +include::{snippets}/block/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/block/save/http-response.adoc[] +include::{snippets}/block/save/response-fields.adoc[] + +=== 블록 수정 API + +==== 요청 + +include::{snippets}/block/update/http-request.adoc[] +include::{snippets}/block/update/path-parameters.adoc[] +include::{snippets}/block/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/block/update/http-response.adoc[] +include::{snippets}/block/update/response-fields.adoc[] + +=== 블록 상태 수정 API + +==== 요청 + +include::{snippets}/block/progress/update/http-request.adoc[] +include::{snippets}/block/progress/update/path-parameters.adoc[] +include::{snippets}/block/progress/update/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/progress/update/http-response.adoc[] +include::{snippets}/block/progress/update/response-fields.adoc[] + +=== 블록 상태 수정 실패 API (400 Bad Request) + +==== 요청 + +include::{snippets}/block/progress/update/failure/http-request.adoc[] +include::{snippets}/block/progress/update/failure/path-parameters.adoc[] +include::{snippets}/block/progress/update/failure/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/progress/update/failure/http-response.adoc[] + +=== 블록 상태별 전체 조회 + +==== 요청 + +include::{snippets}/block/findByBlockWithProgress/http-request.adoc[] +include::{snippets}/block/findByBlockWithProgress/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/findByBlockWithProgress/http-response.adoc[] +include::{snippets}/block/findByBlockWithProgress/response-fields.adoc[] + +=== 블록 상세 조회 + +==== 요청 + +include::{snippets}/block/findById/http-request.adoc[] +include::{snippets}/block/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/findById/http-response.adoc[] +include::{snippets}/block/findById/response-fields.adoc[] + +=== 블록 삭제 + +==== 요청 + +include::{snippets}/block/delete/http-request.adoc[] +include::{snippets}/block/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/delete/http-response.adoc[] + +=== 블록 상태 변경 + +==== 요청 + +include::{snippets}/block/change/http-request.adoc[] + +==== 응답 + +include::{snippets}/block/change/http-response.adoc[] + +=== 삭제된 블록 조회 + +==== 요청 + +include::{snippets}/block/findDeletedBlocks/http-request.adoc[] +include::{snippets}/block/findDeletedBlocks/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/findDeletedBlocks/http-response.adoc[] +include::{snippets}/block/findDeletedBlocks/response-fields.adoc[] + +=== 블록 영구 삭제 + +==== 요청 + +include::{snippets}/block/deletePermanentBlock/http-request.adoc[] +include::{snippets}/block/deletePermanentBlock/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/deletePermanentBlock/http-response.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/challenge.adoc b/src/docs/asciidoc/challenge.adoc new file mode 100644 index 00000000..17b09457 --- /dev/null +++ b/src/docs/asciidoc/challenge.adoc @@ -0,0 +1,266 @@ += 챌린지 API 문서 + +== 열거형 타입 항목 설명 + +이 문서에서는 챌린지 관련 API에서 사용되는 다양한 열거형 타입의 항목들을 설명합니다. +아래의 각 열거형 타입은 API 요청 및 응답에서 사용될 수 있으며, 각 항목은 Name과 Description으로 구성되어 있습니다. + +- Name: 코드에서 사용되는 열거형 상수의 이름입니다. +- Description: 열거형 상수에 대한 설명입니다.. + +=== category(카테고리) 항목 설명 + +[cols="1,1",options="header"] +|=== +| Name | Description + +| HEALTH_AND_FITNESS +| 건강 및 운동 + +| MENTAL_WELLNESS +| 정신 및 마음관리 + +| PRODUCTIVITY_AND_TIME_MANAGEMENT +| 생산성 및 시간 관리 + +| FINANCE_AND_ASSET_MANAGEMENT +| 재정 및 자산 관리 + +| SELF_DEVELOPMENT +| 자기 개발 + +| LIFE_ORGANIZATION_AND_MANAGEMENT +| 생활 정리 및 관리 + +| SOCIAL_CONNECTIONS +| 사회적 연결 + +| CREATIVITY_AND_ARTS +| 창의력 및 예술 활동 + +| OTHERS +| 기타 +|=== + +=== cycle(주기) 항목 설명 + +[cols="1,1",options="header"] +|=== +| Name | Description + +| DAILY +| 매일 + +| WEEKLY +| 매주 + +| MONTHLY +| 매달 +|=== + +=== cycleDetail(주기 상세 정보) 항목 설명 + +[cols="1,1",options="header"] +|=== +| Name | Description + +| DAILY +| 매일 + +| MON +| 월요일 + +| TUE +| 화요일 + +| WED +| 수요일 + +| THU +| 목요일 + +| FRI +| 금요일 + +| SAT +| 토요일 + +| SUN +| 일요일 + +| FIRST +| 1일 + +| SECOND +| 2일 + +| THIRD +| 3일 + +| FOURTH +| 4일 + +| FIFTH +| 5일 + +| SIXTH +| 6일 + +| SEVENTH +| 7일 + +| EIGHTH +| 8일 + +| NINTH +| 9일 + +| TENTH +| 10일 + +| ELEVENTH +| 11일 + +| TWELFTH +| 12일 + +| THIRTEENTH +| 13일 + +| FOURTEENTH +| 14일 + +| FIFTEENTH +| 15일 + +| SIXTEENTH +| 16일 + +| SEVENTEENTH +| 17일 + +| EIGHTEENTH +| 18일 + +| NINETEENTH +| 19일 + +| TWENTIETH +| 20일 + +| TWENTY_FIRST +| 21일 + +| TWENTY_SECOND +| 22일 + +| TWENTY_THIRD +| 23일 + +| TWENTY_FOURTH +| 24일 + +| TWENTY_FIFTH +| 25일 + +| TWENTY_SIXTH +| 26일 + +| TWENTY_SEVENTH +| 27일 + +| TWENTY_EIGHTH +| 28일 + +| TWENTY_NINTH +| 29일 + +| THIRTIETH +| 30일 + +| THIRTY_FIRST +| 31일 +|=== + +== 챌린지 생성 API + +=== 요청 + +include::{snippets}/challenge/save/http-request.adoc[] +include::{snippets}/challenge/save/request-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/save/http-response.adoc[] +include::{snippets}/challenge/save/response-fields.adoc[] + +== 챌린지 수정 API + +=== 요청 + +include::{snippets}/challenge/update/http-request.adoc[] +include::{snippets}/challenge/update/path-parameters.adoc[] +include::{snippets}/challenge/update/request-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/update/http-response.adoc[] +include::{snippets}/challenge/update/response-fields.adoc[] + +== 챌린지 전체 조회 API + +=== 요청 + +include::{snippets}/challenge/findAll/http-request.adoc[] +include::{snippets}/challenge/findAll/response-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/findAll/http-response.adoc[] +include::{snippets}/challenge/findAll/response-fields.adoc[] + +== 챌린지 검색 API + +=== 요청 + +include::{snippets}/challenge/search/http-request.adoc[] +include::{snippets}/challenge/search/response-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/search/http-response.adoc[] +include::{snippets}/challenge/search/response-fields.adoc[] + +== 챌린지 상세 조회 API + +=== 요청 + +include::{snippets}/challenge/findById/http-request.adoc[] +include::{snippets}/challenge/findById/path-parameters.adoc + +=== 응답 + +include::{snippets}/challenge/findById/http-response.adoc[] +include::{snippets}/challenge/findById/response-fields.adoc[] + +== 챌린지 삭제 API + +=== 요청 + +include::{snippets}/challenge/delete/http-request.adoc[] +include::{snippets}/challenge/delete/path-parameters.adoc[] + +=== 응답 + +include::{snippets}/challenge/delete/http-response.adoc[] + +== 챌린지 참여 API + +include::{snippets}/challenge/addChallengeToPersonalDashboard/http-request.adoc[] +include::{snippets}/challenge/addChallengeToPersonalDashboard/path-parameters.adoc[] + +=== 응답 + +include::{snippets}/challenge/addChallengeToPersonalDashboard/http-response.adoc[] + + diff --git a/src/docs/asciidoc/document.adoc b/src/docs/asciidoc/document.adoc new file mode 100644 index 00000000..9261b878 --- /dev/null +++ b/src/docs/asciidoc/document.adoc @@ -0,0 +1,49 @@ +== 문서 API 문서 + +=== 문서 생성 API + +==== 요청 + +include::{snippets}/document/save/http-request.adoc[] +include::{snippets}/document/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/document/save/http-response.adoc[] +include::{snippets}/document/save/response-fields.adoc[] + +=== 문서 수정 API + +==== 요청 + +include::{snippets}/document/update/http-request.adoc[] +include::{snippets}/document/update/path-parameters.adoc[] +include::{snippets}/document/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/document/update/http-response.adoc[] +include::{snippets}/document/update/response-fields.adoc[] + +=== 문서 리스트 조회 API + +==== 요청 + +include::{snippets}/document/findForDocumentByTeamDashboardId/http-request.adoc[] +include::{snippets}/document/findForDocumentByTeamDashboardId/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/document/findForDocumentByTeamDashboardId/http-response.adoc[] +include::{snippets}/document/findForDocumentByTeamDashboardId/response-fields.adoc[] + +=== 문서 삭제 API + +==== 요청 + +include::{snippets}/document/delete/http-request.adoc[] +include::{snippets}/document/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/document/delete/http-response.adoc[] diff --git a/src/docs/asciidoc/file.adoc b/src/docs/asciidoc/file.adoc new file mode 100644 index 00000000..bb83daf7 --- /dev/null +++ b/src/docs/asciidoc/file.adoc @@ -0,0 +1,61 @@ +== 파일 API 문서 + +=== 파일 저장 API + +==== 요청 + +include::{snippets}/file/save/http-request.adoc[] +include::{snippets}/file/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/file/save/http-response.adoc[] +include::{snippets}/file/save/response-fields.adoc[] + +=== 파일 수정 API + +==== 요청 + +include::{snippets}/file/update/http-request.adoc[] +include::{snippets}/file/update/path-parameters.adoc[] +include::{snippets}/file/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/file/update/http-response.adoc[] +include::{snippets}/file/update/response-fields.adoc[] + +=== 파일 리스트 조회 API + +==== 요청 + +include::{snippets}/file/findForFile/http-request.adoc[] +include::{snippets}/file/findForFile/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/file/findForFile/http-response.adoc[] +include::{snippets}/file/findForFile/response-fields.adoc[] + +=== 파일 상세 조회 API + +==== 요청 + +include::{snippets}/file/findById/http-request.adoc[] +include::{snippets}/file/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/file/findById/http-response.adoc[] +include::{snippets}/file/findById/response-fields.adoc[] + +=== 파일 삭제 API + +==== 요청 + +include::{snippets}/file/delete/http-request.adoc[] +include::{snippets}/file/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/file/delete/http-response.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..f9dd6da4 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,12 @@ += 끄적끄적 API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 + +include::block.adoc[] +include::challenge.adoc[] + +include::personalDashboard.adoc[] +include::notification.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/login.adoc b/src/docs/asciidoc/login.adoc new file mode 100644 index 00000000..17c23586 --- /dev/null +++ b/src/docs/asciidoc/login.adoc @@ -0,0 +1,25 @@ +== 로그인 API 문서 + +=== 로그인 API + +==== 요청 + +include::{snippets}/auth/login/http-request.adoc[] +include::{snippets}/auth/login/request-fields.adoc[] + +==== 응답 + +include::{snippets}/auth/login/http-response.adoc[] +include::{snippets}/auth/login/response-fields.adoc[] + +=== 리프레쉬 토큰으로 액세스 토큰 발급 API + +==== 요청 + +include::{snippets}/auth/getNewAccessToken/http-request.adoc[] +include::{snippets}/auth/getNewAccessToken/request-fields.adoc[] + +==== 응답 + +include::{snippets}/auth/getNewAccessToken/http-response.adoc[] +include::{snippets}/auth/getNewAccessToken/response-fields.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/member.adoc b/src/docs/asciidoc/member.adoc new file mode 100644 index 00000000..95ce8bfb --- /dev/null +++ b/src/docs/asciidoc/member.adoc @@ -0,0 +1,40 @@ +== 멤버 API 문서 + +=== 내 프로필 정보 조회 API + +==== 요청 + +include::{snippets}/member/mypage/http-request.adoc[] +include::{snippets}/member/mypage/request-headers.adoc[] + +==== 응답 + +include::{snippets}/member/mypage/http-response.adoc[] +include::{snippets}/member/mypage/response-fields.adoc[] + +=== 팀 대시보드와 챌린지 정보 조회 API + +==== 요청 + +include::{snippets}/member/team-challenges/http-request.adoc[] +include::{snippets}/member/team-challenges/request-headers.adoc[] +include::{snippets}/member/team-challenges/query-parameters.adoc[] + + +==== 응답 + +include::{snippets}/member/team-challenges/http-response.adoc[] +include::{snippets}/member/team-challenges/response-fields.adoc[] + +=== 내 프로필 정보 수정 API + +==== 요청 + +include::{snippets}/member/mypage-update/http-request.adoc[] +include::{snippets}/member/mypage-update/request-headers.adoc[] +include::{snippets}/member/mypage-update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/member/mypage/http-response.adoc[] +include::{snippets}/member/mypage/response-fields.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/notification.adoc b/src/docs/asciidoc/notification.adoc new file mode 100644 index 00000000..09a23c3d --- /dev/null +++ b/src/docs/asciidoc/notification.adoc @@ -0,0 +1,35 @@ +== 알림 API 문서 + +=== 알림 등록 API + +==== 요청 + +include::{snippets}/notification/stream/http-request.adoc[] +include::{snippets}/notification/stream/request-headers.adoc[] + +==== 응답 + +include::{snippets}/notification/stream/http-response.adoc[] + +=== 알림 전체 리스트 조회 + +==== 요청 + +include::{snippets}/notification/findAll/http-request.adoc[] +include::{snippets}/notification/findAll/request-headers.adoc[] + +==== 응답 + +include::{snippets}/notification/findAll/http-response.adoc[] +include::{snippets}/notification/findAll/response-fields.adoc[] + +=== 알림 상세 조회 + +==== 요청 + +include::{snippets}/notification/findById/http-request.adoc[] + +==== 응답 + +include::{snippets}/notification/findById/http-response.adoc[] +include::{snippets}/notification/findById/response-fields.adoc[] diff --git a/src/docs/asciidoc/personalDashboard.adoc b/src/docs/asciidoc/personalDashboard.adoc new file mode 100644 index 00000000..4b6a4d7c --- /dev/null +++ b/src/docs/asciidoc/personalDashboard.adoc @@ -0,0 +1,77 @@ +== 개인 대시보드 API 문서 + +=== 개인 대시보드 생성 API + +==== 요청 + +include::{snippets}/dashboard/personal/save/http-request.adoc[] +include::{snippets}/dashboard/personal/save/request-headers.adoc[] +include::{snippets}/dashboard/personal/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/save/http-response.adoc[] +include::{snippets}/dashboard/personal/save/response-fields.adoc[] + +=== 개인 대시보드 수정 API + +==== 요청 + +include::{snippets}/dashboard/personal/update/http-request.adoc[] +include::{snippets}/dashboard/personal/update/request-headers.adoc[] +include::{snippets}/dashboard/personal/update/path-parameters.adoc[] +include::{snippets}/dashboard/personal/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/update/http-response.adoc[] +include::{snippets}/dashboard/personal/update/response-fields.adoc[] + +=== 개인 대시보드 전체 조회 + +==== 요청 + +include::{snippets}/dashboard/personal/findForPersonalDashboard/http-request.adoc[] +include::{snippets}/dashboard/personal/findForPersonalDashboard/request-headers.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/findForPersonalDashboard/http-response.adoc[] +include::{snippets}/dashboard/personal/findForPersonalDashboard/response-fields.adoc[] + +=== 개인 대시보드 상세 조회 + +==== 요청 + +include::{snippets}/dashboard/personal/findById/http-request.adoc[] +include::{snippets}/dashboard/personal/findById/request-headers.adoc[] +include::{snippets}/dashboard/personal/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/findById/http-response.adoc[] +include::{snippets}/dashboard/personal/findById/response-fields.adoc[] + +=== 사용자의 개인 대시보드 카테고리 조회 + +==== 요청 + +include::{snippets}/dashboard/personal/categories/http-request.adoc[] +include::{snippets}/dashboard/personal/categories/request-headers.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/categories/http-response.adoc[] +include::{snippets}/dashboard/personal/categories/response-fields.adoc[] + +=== 개인 대시보드 삭제 API + +==== 요청 + +include::{snippets}/dashboard/personal/delete/http-request.adoc[] +include::{snippets}/dashboard/personal/delete/request-headers.adoc[] +include::{snippets}/dashboard/personal/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/delete/http-response.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/teamDashboard.adoc b/src/docs/asciidoc/teamDashboard.adoc new file mode 100644 index 00000000..278c275d --- /dev/null +++ b/src/docs/asciidoc/teamDashboard.adoc @@ -0,0 +1,93 @@ +== 팀 대시보드 API 문서 + +=== 팀 대시보드 생성 API + +==== 요청 + +include::{snippets}/dashboard/team/save/http-request.adoc[] +include::{snippets}/dashboard/team/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/save/http-response.adoc[] +include::{snippets}/dashboard/team/save/response-fields.adoc[] + +=== 팀 대시보드 수정 API + +==== 요청 + +include::{snippets}/dashboard/team/update/http-request.adoc[] +include::{snippets}/dashboard/team/update/path-parameters.adoc[] +include::{snippets}/dashboard/team/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/update/http-response.adoc[] +include::{snippets}/dashboard/team/update/response-fields.adoc[] + +=== 팀 대시보드 전체 조회 + +==== 요청 + +include::{snippets}/dashboard/team/findForTeamDashboard/http-request.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/findForTeamDashboard/http-response.adoc[] +include::{snippets}/dashboard/team/findForTeamDashboard/response-fields.adoc[] + +=== 팀 대시보드 상세 조회 + +==== 요청 + +include::{snippets}/dashboard/team/findById/http-request.adoc[] +include::{snippets}/dashboard/team/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/findById/http-response.adoc[] +include::{snippets}/dashboard/team/findById/response-fields.adoc[] + +=== 팀 대시보드 삭제 + +==== 요청 + +include::{snippets}/dashboard/team/delete/http-request.adoc[] +include::{snippets}/dashboard/team/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/delete/http-response.adoc[] + +=== 팀 대시보드 초대 멤버 리스트 조회 + +==== 요청 + +include::{snippets}/dashboard/team/search/http-request.adoc[] +include::{snippets}/dashboard/team/search/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/search/http-response.adoc[] + +=== 팀 대시보드 참여 초대 수락 + +==== 요청 + +include::{snippets}/dashboard/team/join/http-request.adoc[] +include::{snippets}/dashboard/team/join/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/join/http-response.adoc[] + +=== 팀 대시보드 탈퇴 + +==== 요청 + +include::{snippets}/dashboard/team/leave/http-request.adoc[] +include::{snippets}/dashboard/team/leave/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/leave/http-response.adoc[] diff --git a/src/docs/asciidoc/teamDocument.adoc b/src/docs/asciidoc/teamDocument.adoc new file mode 100644 index 00000000..a5f33494 --- /dev/null +++ b/src/docs/asciidoc/teamDocument.adoc @@ -0,0 +1,74 @@ +== Team Document API Documentation + +=== Team Document 생성 API + +==== 요청 + +include::{snippets}/teamDocument/save/http-request.adoc[] +include::{snippets}/teamDocument/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/teamDocument/update/http-response.adoc[] +include::{snippets}/teamDocument/update/response-fields.adoc[] + +=== Team Document 수정 API + +==== 요청 + +include::{snippets}/teamDocument/update/http-request.adoc[] +include::{snippets}/teamDocument/update/path-parameters.adoc[] +include::{snippets}/teamDocument/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/teamDocument/update/http-response.adoc[] +include::{snippets}/teamDocument/update/response-fields.adoc[] + +=== Team Document 상세 조회 API + +==== 요청 + +include::{snippets}/teamDocument/findById/http-request.adoc[] +include::{snippets}/teamDocument/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/teamDocument/findById/http-response.adoc[] +include::{snippets}/teamDocument/findById/response-fields.adoc[] + +=== Team Document 카테고리 조회 API + +==== 요청 + +include::{snippets}/teamDocument/findCategories/http-request.adoc[] +include::{snippets}/teamDocument/findCategories/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/teamDocument/findCategories/http-response.adoc[] +include::{snippets}/teamDocument/findCategories/response-fields.adoc[] + +=== Team Document 카테고리별 검색 API + +==== 요청 + +include::{snippets}/teamDocument/search-by-category/http-request.adoc[] +include::{snippets}/teamDocument/search-by-category/query-parameters.adoc[] +include::{snippets}/teamDocument/search-by-category/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/teamDocument/search-by-category/http-response.adoc[] +include::{snippets}/teamDocument/search-by-category/response-fields.adoc[] + +=== Team Document 삭제 API + +==== 요청 + +include::{snippets}/teamDocument/delete/http-request.adoc[] +include::{snippets}/teamDocument/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/teamDocument/delete/http-response.adoc[] diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java index 328d2c3e..d0ab759b 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.IdTokenResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; import shop.kkeujeok.kkeujeokbackend.auth.application.AuthMemberService; @@ -19,28 +20,23 @@ import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; -@RestController @Slf4j +@RestController @RequestMapping("/api") @RequiredArgsConstructor public class AuthController { + private final AuthServiceFactory authServiceFactory; private final AuthMemberService memberService; private final TokenService tokenService; - private final GoogleAuthService getGoogleAccessToken; - private final KakaoAuthService kakaoAuthService; - @GetMapping("oauth2/callback/google") - public JsonNode googleCallback(@RequestParam(name = "code") String code) { - return getGoogleAccessToken.getGoogleIdToken(code); - } - - @GetMapping("oauth2/callback/kakao") - public JsonNode kakaoCallback(@RequestParam(name = "code") String code) { - return kakaoAuthService.getKakaoAccessToken(code); + @GetMapping("oauth2/callback/{provider}") + public IdTokenResDto callback(@PathVariable(name = "provider") String provider, + @RequestParam(name = "code") String code) { + AuthService authService = authServiceFactory.getAuthService(provider); + return authService.getIdToken(code); } -// @Operation(summary = "로그인 후 토큰 발급", description = "액세스, 리프레쉬 토큰을 발급합니다.") @PostMapping("/{provider}/token") public RspTemplate generateAccessAndRefreshToken( @PathVariable(name = "provider") String provider, @@ -55,12 +51,10 @@ public RspTemplate generateAccessAndRefreshToken( return new RspTemplate<>(HttpStatus.OK, "토큰 발급", getToken); } -// @Operation(summary = "액세스 토큰 재발급", description = "리프레쉬 토큰으로 액세스 토큰을 발급합니다.") @PostMapping("/token/access") public RspTemplate generateAccessToken(@RequestBody RefreshTokenReqDto refreshTokenReqDto) { TokenDto getToken = tokenService.generateAccessToken(refreshTokenReqDto); return new RspTemplate<>(HttpStatus.OK, "액세스 토큰 발급", getToken); } - } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/IdTokenResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/IdTokenResDto.java new file mode 100644 index 00000000..faa4dc74 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/IdTokenResDto.java @@ -0,0 +1,8 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api.dto.response; + +import com.fasterxml.jackson.databind.JsonNode; + +public record IdTokenResDto( + JsonNode idToken +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java index 707e90e3..44399963 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java @@ -1,25 +1,30 @@ package shop.kkeujeok.kkeujeokbackend.auth.application; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.auth.exception.EmailNotFoundException; +import shop.kkeujeok.kkeujeokbackend.auth.exception.ExistsMemberEmailException; import shop.kkeujeok.kkeujeokbackend.global.entity.Status; import shop.kkeujeok.kkeujeokbackend.member.domain.Member; import shop.kkeujeok.kkeujeokbackend.member.domain.Role; import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.nickname.application.NicknameService; +import shop.kkeujeok.kkeujeokbackend.member.tag.application.TagService; import java.util.Optional; @Service +@RequiredArgsConstructor @Transactional(readOnly = true) public class AuthMemberService { - private final MemberRepository memberRepository; - public AuthMemberService(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } + private final MemberRepository memberRepository; + private final NicknameService nicknameService; + private final TagService tagService; @Transactional public MemberLoginResDto saveUserInfo(UserInfo userInfo, SocialType provider) { @@ -34,7 +39,7 @@ public MemberLoginResDto saveUserInfo(UserInfo userInfo, SocialType provider) { private void validateNotFoundEmail(String email) { if (email == null) { - throw new RuntimeException(); + throw new EmailNotFoundException(); } } @@ -44,18 +49,13 @@ private Member getExistingMemberOrCreateNew(UserInfo userInfo, SocialType provid private Member createMember(UserInfo userInfo, SocialType provider) { String userPicture = getUserPicture(userInfo.picture()); - String name = userInfo.name(); - String nickname = userInfo.nickname(); - - if (name == null && nickname != null) { - name = nickname; - } else if (nickname == null && name != null) { - nickname = name; - } + String name = unionName(userInfo.name(), userInfo.nickname()); + String nickname = nicknameService.getRandomNickname(); + String tag = tagService.getRandomTag(nickname); return memberRepository.save( Member.builder() - .status(Status.A) + .status(Status.ACTIVE) .email(userInfo.email()) .name(name) .picture(userPicture) @@ -63,13 +63,20 @@ private Member createMember(UserInfo userInfo, SocialType provider) { .role(Role.ROLE_USER) .firstLogin(true) .nickname(nickname) + .introduction("자기 소개를 입력해 주세요.") + .tag(tag) .build() ); } + private String unionName(String name, String nickname) { + return nickname != null ? nickname : name; + } + private String getUserPicture(String picture) { return Optional.ofNullable(picture) - .map(this::convertToHighRes).orElseThrow(); + .map(this::convertToHighRes) + .orElseThrow(); } private String convertToHighRes(String url){ @@ -78,8 +85,7 @@ private String convertToHighRes(String url){ private void validateSocialType(Member member, SocialType provider) { if (!provider.equals(member.getSocialType())) { - throw new RuntimeException(); + throw new ExistsMemberEmailException(); } } - } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java index 06e4220e..e5845816 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java @@ -1,9 +1,13 @@ package shop.kkeujeok.kkeujeokbackend.auth.application; +import com.fasterxml.jackson.databind.JsonNode; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.IdTokenResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; public interface AuthService { UserInfo getUserInfo(String authCode); String getProvider(); + + IdTokenResDto getIdToken(String code); } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java index a6a34533..6adfc363 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java @@ -9,12 +9,13 @@ @Service public class AuthServiceFactory { + private final Map authServiceMap; @Autowired - public AuthServiceFactory(List authServiceList) { + public AuthServiceFactory(List authServices) { authServiceMap = new HashMap<>(); - for (AuthService authService : authServiceList) { + for (AuthService authService : authServices) { authServiceMap.put(authService.getProvider(), authService); } } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java index 4a67bd8f..12974902 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java @@ -1,9 +1,11 @@ package shop.kkeujeok.kkeujeokbackend.auth.application; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.exception.InvalidTokenException; import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.Token; @@ -12,6 +14,7 @@ import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; @Service +@RequiredArgsConstructor @Transactional(readOnly = true) public class TokenService { @@ -19,12 +22,6 @@ public class TokenService { private final TokenRepository tokenRepository; private final MemberRepository memberRepository; - public TokenService(TokenProvider tokenProvider, TokenRepository tokenRepository, MemberRepository memberRepository) { - this.tokenProvider = tokenProvider; - this.tokenRepository = tokenRepository; - this.memberRepository = memberRepository; - } - @Transactional public TokenDto getToken(MemberLoginResDto memberLoginResDto) { TokenDto tokenDto = tokenProvider.generateToken(memberLoginResDto.findMember().getEmail()); @@ -52,8 +49,8 @@ private void refreshTokenUpdate(MemberLoginResDto memberLoginResDto, TokenDto to @Transactional public TokenDto generateAccessToken(RefreshTokenReqDto refreshTokenReqDto) { - if (!tokenRepository.existsByRefreshToken(refreshTokenReqDto.refreshToken()) || !tokenProvider.validateToken(refreshTokenReqDto.refreshToken())) { - throw new RuntimeException(); + if (isInvalidRefreshToken(refreshTokenReqDto.refreshToken())) { + throw new InvalidTokenException(); } Token token = tokenRepository.findByRefreshToken(refreshTokenReqDto.refreshToken()).orElseThrow(); @@ -62,4 +59,7 @@ public TokenDto generateAccessToken(RefreshTokenReqDto refreshTokenReqDto) { return tokenProvider.generateAccessTokenByRefreshToken(member.getEmail(), token.getRefreshToken()); } + private boolean isInvalidRefreshToken(String refreshToken) { + return !tokenRepository.existsByRefreshToken(refreshToken) || !tokenProvider.validateToken(refreshToken); + } } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/EmailNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/EmailNotFoundException.java new file mode 100644 index 00000000..de9757d1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/EmailNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.auth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class EmailNotFoundException extends NotFoundGroupException { + public EmailNotFoundException(String message) { + super(message); + } + + public EmailNotFoundException() { + this("존재하지 않는 이메일 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/ExistsMemberEmailException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/ExistsMemberEmailException.java new file mode 100644 index 00000000..a8041770 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/ExistsMemberEmailException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.auth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class ExistsMemberEmailException extends InvalidGroupException { + public ExistsMemberEmailException(String message) { + super(message); + } + + public ExistsMemberEmailException() { + this("이미 가입한 계정이 있는 이메일 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/InvalidTokenException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/InvalidTokenException.java new file mode 100644 index 00000000..b66046e7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/InvalidTokenException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.auth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AuthGroupException; + +public class InvalidTokenException extends AuthGroupException { + public InvalidTokenException(String message) { + super(message); + } + + public InvalidTokenException() { + this("토큰이 유효하지 않습니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java index 390f0bee..0adfd697 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java @@ -1,7 +1,10 @@ package shop.kkeujeok.kkeujeokbackend.block.api; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -10,32 +13,89 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; import shop.kkeujeok.kkeujeokbackend.block.application.BlockService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; @RestController @RequiredArgsConstructor @RequestMapping("/api/blocks") public class BlockController { + private final BlockService blockService; @PostMapping("/") - public RspTemplate save(@RequestBody BlockSaveReqDto blockSaveReqDto) { - return new RspTemplate<>(HttpStatus.CREATED, "블럭 생성", blockService.save(blockSaveReqDto)); + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody BlockSaveReqDto blockSaveReqDto) { + return new RspTemplate<>(HttpStatus.CREATED, "블럭 생성", blockService.save(email, blockSaveReqDto)); } @PatchMapping("/{blockId}") - public RspTemplate update(@PathVariable(name = "blockId") Long blockId, + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId, @RequestBody BlockUpdateReqDto blockUpdateReqDto) { - return new RspTemplate<>(HttpStatus.OK, "블록 수정", blockService.update(blockId, blockUpdateReqDto)); + return new RspTemplate<>(HttpStatus.OK, "블록 수정", blockService.update(email, blockId, blockUpdateReqDto)); } @PatchMapping("/{blockId}/progress") - public RspTemplate progressUpdate(@PathVariable(name = "blockId") Long blockId, + public RspTemplate progressUpdate(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId, @RequestParam(name = "progress") String progress) { - return new RspTemplate<>(HttpStatus.OK, "블록 상태 수정", blockService.progressUpdate(blockId, progress)); + return new RspTemplate<>(HttpStatus.OK, "블록 상태 수정", blockService.progressUpdate(email, blockId, progress)); + } + + @GetMapping("") + public RspTemplate findForBlockByProgress(@CurrentUserEmail String email, + @RequestParam(name = "dashboardId") Long dashboardId, + @RequestParam(name = "progress") String progress, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + return new RspTemplate<>(HttpStatus.OK, + "블록 상태별 전체 조회", + blockService.findForBlockByProgress(email, dashboardId, progress, PageRequest.of(page, size))); + } + + @DeleteMapping("/{blockId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId) { + blockService.delete(email, blockId); + return new RspTemplate<>(HttpStatus.OK, "블록 삭제, 복구"); + } + + @GetMapping("/{blockId}") + public RspTemplate findById(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId) { + return new RspTemplate<>(HttpStatus.OK, "블록 상세보기", blockService.findById(email, blockId)); } - + + @PatchMapping("/change") + public RspTemplate changeBlocksSequence(@CurrentUserEmail String email, + @RequestBody BlockSequenceUpdateReqDto blockSequenceUpdateReqDto) { + blockService.changeBlocksSequence(email, blockSequenceUpdateReqDto); + return new RspTemplate<>(HttpStatus.OK, "블록 순서 변경"); + } + + @GetMapping("/deleted") + public RspTemplate findDeletedBlocks(@CurrentUserEmail String email, + @RequestParam(name = "dashboardId") Long dashboardId, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + BlockListResDto deletedBlocks = blockService.findDeletedBlocks(email, dashboardId, PageRequest.of(page, size)); + return new RspTemplate<>(HttpStatus.OK, + "삭제된 블록 조회", + deletedBlocks); + } + + @DeleteMapping("/permanent/{blockId}") + public RspTemplate deletePermanently(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId) { + blockService.deletePermanently(email, blockId); + return new RspTemplate<>(HttpStatus.OK, + "블록 영구 삭제"); + } + } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java index a59da811..480480a6 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java @@ -2,19 +2,29 @@ import shop.kkeujeok.kkeujeokbackend.block.domain.Block; import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; import shop.kkeujeok.kkeujeokbackend.member.domain.Member; public record BlockSaveReqDto( + Long dashboardId, String title, String contents, - Progress progress + Progress progress, + String startDate, + String deadLine ) { - public Block toEntity(Member member) { + public Block toEntity(Member member, Dashboard dashboard, int lastSequence) { return Block.builder() .title(title) .contents(contents) .progress(progress) + .type(Type.BASIC) + .startDate(startDate) + .deadLine(deadLine) + .sequence(lastSequence + 1) .member(member) + .dashboard(dashboard) .build(); } } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSequenceUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSequenceUpdateReqDto.java new file mode 100644 index 00000000..fe291c90 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSequenceUpdateReqDto.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.request; + +import java.util.List; + +public record BlockSequenceUpdateReqDto( + List notStartedList, + List inProgressList, + List completedList +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java index 86237d91..8dc2df74 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java @@ -2,6 +2,8 @@ public record BlockUpdateReqDto( String title, - String contents + String contents, + String startDate, + String deadLine ) { } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java index e7672847..c89cec3d 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java @@ -1,23 +1,49 @@ package shop.kkeujeok.kkeujeokbackend.block.api.dto.response; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import lombok.Builder; import shop.kkeujeok.kkeujeokbackend.block.domain.Block; import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; -import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; @Builder public record BlockInfoResDto( + Long blockId, String title, String contents, Progress progress, - String nickname + Type type, + String dType, + String startDate, + String deadLine, + String nickname, + String picture, + int dDay ) { - public static BlockInfoResDto of(Block block, Member member) { + public static BlockInfoResDto from(Block block) { return BlockInfoResDto.builder() + .blockId(block.getId()) .title(block.getTitle()) .contents(block.getContents()) .progress(block.getProgress()) - .nickname(member.getNickname()) + .type(block.getType()) + .dType(block.getDashboard().getDType()) + .startDate(block.getStartDate()) + .deadLine(block.getDeadLine()) + .nickname(block.getMember().getNickname()) + .picture(block.getMember().getPicture()) + .dDay(calculateDDay(block.getDeadLine())) .build(); } + + private static int calculateDDay(String deadlineStr) { + LocalDateTime deadline = LocalDateTime.parse(deadlineStr, DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm")); + LocalDate today = LocalDate.now(); + LocalDate deadlineDate = deadline.toLocalDate(); + return Math.toIntExact(ChronoUnit.DAYS.between(today, deadlineDate)); + } + } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockListResDto.java new file mode 100644 index 00000000..615c3ef7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockListResDto.java @@ -0,0 +1,18 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record BlockListResDto( + List blockListResDto, + PageInfoResDto pageInfoResDto +) { + public static BlockListResDto from(List blocks, PageInfoResDto pageInfoResDto) { + return BlockListResDto.builder() + .blockListResDto(blocks) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java index e3c78828..c500b0a5 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java @@ -1,63 +1,154 @@ package shop.kkeujeok.kkeujeokbackend.block.application; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; import shop.kkeujeok.kkeujeokbackend.block.domain.Block; import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; import shop.kkeujeok.kkeujeokbackend.block.exception.BlockNotFoundException; import shop.kkeujeok.kkeujeokbackend.block.exception.InvalidProgressException; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; import shop.kkeujeok.kkeujeokbackend.member.domain.Member; import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class BlockService { + private final MemberRepository memberRepository; private final BlockRepository blockRepository; + private final DashboardRepository dashboardRepository; // 블록 생성 @Transactional - public BlockInfoResDto save(BlockSaveReqDto blockSaveReqDto) { - // 로그인/회원가입 코드 완성 후 사용자 정보 받아올 예정 - Member member = Member.builder().nickname("member").build(); - Block block = blockRepository.save(blockSaveReqDto.toEntity(member)); + public BlockInfoResDto save(String email, BlockSaveReqDto blockSaveReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Dashboard dashboard = dashboardRepository.findById(blockSaveReqDto.dashboardId()) + .orElseThrow(DashboardNotFoundException::new); + + int lastSequence = blockRepository.findLastSequenceByProgress( + member, + dashboard.getId(), + blockSaveReqDto.progress()); + + Block block = blockRepository.save(blockSaveReqDto.toEntity(member, dashboard, lastSequence)); - return BlockInfoResDto.of(block, member); + return BlockInfoResDto.from(block); } // 블록 수정 (자동 수정 예정) @Transactional - public BlockInfoResDto update(Long blockId, BlockUpdateReqDto blockUpdateReqDto) { - // 로그인/회원가입 코드 완성 후 사용자 정보 받아올 예정 - Member member = Member.builder().nickname("member").build(); + public BlockInfoResDto update(String email, Long blockId, BlockUpdateReqDto blockUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); - if (!block.getTitle().equals(blockUpdateReqDto.title()) || - !block.getContents().equals(blockUpdateReqDto.contents())) { - block.update(blockUpdateReqDto.title(), blockUpdateReqDto.contents()); - } + block.update(blockUpdateReqDto.title(), + blockUpdateReqDto.contents(), + blockUpdateReqDto.startDate(), + blockUpdateReqDto.deadLine()); - return BlockInfoResDto.of(block, member); + return BlockInfoResDto.from(block); } // 블록 상태 업데이트 (Progress) @Transactional - public BlockInfoResDto progressUpdate(Long blockId, String progressString) { - // 로그인/회원가입 코드 완성 후 사용자 정보 받아올 예정 - Member member = Member.builder().nickname("member").build(); + public BlockInfoResDto progressUpdate(String email, Long blockId, String progressString) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); Progress progress = parseProgress(progressString); block.progressUpdate(progress); - return BlockInfoResDto.of(block, member); + return BlockInfoResDto.from(block); + } + + // 블록 리스트 + public BlockListResDto findForBlockByProgress(String email, Long dashboardId, String progress, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Page blocks = blockRepository.findByBlockWithProgress(dashboardId, parseProgress(progress), pageable); + + List blockInfoResDtoList = blocks.stream() + .map(BlockInfoResDto::from) + .toList(); + + return BlockListResDto.from(blockInfoResDtoList, PageInfoResDto.from(blocks)); + } + + // 블록 상세보기 + public BlockInfoResDto findById(String email, Long blockId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + return BlockInfoResDto.from(block); + } + + // 블록 삭제 유무 업데이트 (논리 삭제) + @Transactional + public void delete(String email, Long blockId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + block.statusUpdate(); + } + + // 블록 순번 변경 + @Transactional + public void changeBlocksSequence(String email, BlockSequenceUpdateReqDto blockSequenceUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + + updateBlockSequence(blockSequenceUpdateReqDto.notStartedList()); + updateBlockSequence(blockSequenceUpdateReqDto.inProgressList()); + updateBlockSequence(blockSequenceUpdateReqDto.completedList()); + } + + private void updateBlockSequence(List blockIds) { + int sequence = blockIds.size(); + + for (Long blockId : blockIds) { + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + if (block.getSequence() != sequence) { + block.sequenceUpdate(sequence); + } + + sequence--; + } + } + + // 삭제된 블록 조회 + public BlockListResDto findDeletedBlocks(String email, Long dashboardId, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Page deletedBlocks = blockRepository.findByDeletedBlocks(dashboardId, pageable); + + List blockInfoResDtoList = deletedBlocks.stream() + .map(BlockInfoResDto::from) + .toList(); + + return BlockListResDto.from(blockInfoResDtoList, PageInfoResDto.from(deletedBlocks)); + } + + // 블록 영구 삭제 + @Transactional + public void deletePermanently(String email, Long blockId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + blockRepository.delete(block); } private Progress parseProgress(String progressString) { @@ -68,10 +159,4 @@ private Progress parseProgress(String progressString) { } } - // 블록 삭제 (논리 삭제) - - // 블록 리스트 - - // 블록 상세보기 - } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java index 81f731d4..36c49c4e 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java @@ -4,12 +4,15 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; import shop.kkeujeok.kkeujeokbackend.global.entity.Status; import shop.kkeujeok.kkeujeokbackend.member.domain.Member; @@ -30,25 +33,77 @@ public class Block extends BaseEntity { @Enumerated(value = EnumType.STRING) private Progress progress; + @Enumerated(value = EnumType.STRING) + private Type type; + + private String startDate; + + private String deadLine; + + private int sequence; + @ManyToOne @JoinColumn(name = "member_id") private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "dashboard_id") + private Dashboard dashboard; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "challenge_id") + private Challenge challenge; + @Builder - private Block(String title, String contents, Progress progress, Member member) { - this.status = Status.A; + private Block(String title, String contents, Progress progress, Type type, Member member, String startDate, + String deadLine, int sequence, Dashboard dashboard, Challenge challenge) { + this.status = Status.ACTIVE; this.title = title; this.contents = contents; this.progress = progress; + this.type = type; + this.startDate = startDate; + this.deadLine = deadLine; + this.sequence = sequence; this.member = member; + this.dashboard = dashboard; + this.challenge = challenge; } - public void update(String title, String contents) { - this.title = title; - this.contents = contents; + public void update(String updateTitle, String updateContents, String updateStartDate, String updateDeadLine) { + if (isUpdateRequired(updateTitle, updateContents, updateStartDate, updateDeadLine)) { + this.title = updateTitle; + this.contents = updateContents; + this.startDate = updateStartDate; + this.deadLine = updateDeadLine; + } + } + + private boolean isUpdateRequired(String updateTitle, String updateContents, String updateStartDate, + String updateDeadLine) { + return !this.title.equals(updateTitle) || + !this.contents.equals(updateContents) || + !this.startDate.equals(updateStartDate) || + !this.deadLine.equals(updateDeadLine); } public void progressUpdate(Progress progress) { this.progress = progress; } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } + + public void updateChallengeStatus(Status status) { + if (this.status == status) { + return; + } + this.status = status; + } + + public void sequenceUpdate(int sequence) { + this.sequence = sequence; + } + } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Type.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Type.java new file mode 100644 index 00000000..7d8cb699 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Type.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain; + +import lombok.Getter; + +@Getter +public enum Type { + BASIC("일반 블록"), + CHALLENGE("챌린지 블록"); + + private final String description; + + Type(String description) { + this.description = description; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepository.java new file mode 100644 index 00000000..8d05351a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepository.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public interface BlockCustomRepository { + Page findByBlockWithProgress(Long dashboardId, Progress progress, Pageable pageable); + + int findLastSequenceByProgress(Member member, Long dashboardId, Progress progress); + + Page findByDeletedBlocks(Long dashboardId, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepositoryImpl.java new file mode 100644 index 00000000..aa2137e8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepositoryImpl.java @@ -0,0 +1,87 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.block.domain.QBlock.block; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Repository +@Transactional(readOnly = true) +public class BlockCustomRepositoryImpl implements BlockCustomRepository { + private final JPAQueryFactory queryFactory; + + public BlockCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findByBlockWithProgress(Long dashboardId, Progress progress, Pageable pageable) { + long total = queryFactory + .selectFrom(block) + .where(block.progress.eq(progress) + .and(block.dashboard.id.eq(dashboardId)) + .and(block.status.eq(Status.ACTIVE))) + .stream() + .count(); + + List blocks = queryFactory + .selectFrom(block) + .where(block.dashboard.id.eq(dashboardId) + .and(block.progress.eq(progress)) + .and(block.status.eq(Status.ACTIVE))) + .orderBy(block.sequence.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(blocks, pageable, total); + } + + @Override + public int findLastSequenceByProgress(Member member, Long dashboardId, Progress progress) { + return Optional.of( + Math.toIntExact( + queryFactory + .select(block.sequence) + .from(block) + .where(block.dashboard.id.eq(dashboardId) + .and(block.progress.eq(progress)) + .and(block.status.eq(Status.ACTIVE))) + .stream() + .count() + ) + ) + .orElse(0); + } + + @Override + public Page findByDeletedBlocks(Long dashboardId, Pageable pageable) { + long total = queryFactory + .selectFrom(block) + .where(block.dashboard.id.eq(dashboardId) + .and(block.status.eq(Status.DELETED))) + .stream() + .count(); + + List blocks = queryFactory + .selectFrom(block) + .where(block.dashboard.id.eq(dashboardId) + .and(block.status.eq(Status.DELETED))) + .orderBy(block.sequence.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(blocks, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java index ca87a433..9a9292a9 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java @@ -1,7 +1,10 @@ package shop.kkeujeok.kkeujeokbackend.block.domain.repository; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; -public interface BlockRepository extends JpaRepository { +public interface BlockRepository extends JpaRepository, BlockCustomRepository { + List findByType(Type type); } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeController.java new file mode 100644 index 00000000..62448dbc --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeController.java @@ -0,0 +1,93 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/challenges") +public class ChallengeController { + + private final ChallengeService challengeService; + + @PostMapping + public RspTemplate save(@CurrentUserEmail String email, + @Valid @RequestBody ChallengeSaveReqDto challengeSaveReqDto) { + return new RspTemplate<>(HttpStatus.CREATED, "챌린지 생성 성공", challengeService.save(email, challengeSaveReqDto)); + } + + @PatchMapping("/{challengeId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "challengeId") Long challengeId, + @Valid @RequestBody ChallengeSaveReqDto challengeSaveReqDto) { + return new RspTemplate<>(HttpStatus.OK, "챌린지 수정 성공", + challengeService.update(email, challengeId, challengeSaveReqDto)); + } + + @GetMapping + public RspTemplate findAllChallenges(@RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, + "챌린지 전체 조회 성공", + challengeService.findAllChallenges(PageRequest.of(page, size))); + } + + @GetMapping("/search") + public RspTemplate findChallengesByKeyWord(@RequestParam(name = "keyword") String keyWord, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + ChallengeSearchReqDto searchReqDto = ChallengeSearchReqDto.from(keyWord); + return new RspTemplate<>(HttpStatus.OK, + "챌린지 검색 성공", + challengeService.findChallengesByKeyWord(searchReqDto, PageRequest.of(page, size))); + } + + @GetMapping("/find") + public RspTemplate findByCategory(@RequestParam(name = "category") String category, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, + "챌린지 카테고리 검색 성공", + challengeService.findByCategory(category, PageRequest.of(page, size))); + } + + @GetMapping("/{challengeId}") + public RspTemplate findById(@PathVariable(name = "challengeId") Long challengeId) { + return new RspTemplate<>(HttpStatus.OK, "챌린지 상세보기", challengeService.findById(challengeId)); + } + + @DeleteMapping("/{challengeId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "challengeId") Long challengeId) { + challengeService.delete(email, challengeId); + return new RspTemplate<>(HttpStatus.OK, "챌린지 삭제 성공"); + } + + @PostMapping("/{challengeId}/{dashboardId}") + public RspTemplate addChallengeToPersonalDashboard(@CurrentUserEmail String email, + @PathVariable(name = "challengeId") Long challengeId, + @PathVariable(name = "dashboardId") Long personalDashboardId) { + return new RspTemplate<>(HttpStatus.OK, + "챌린지 참여 성공", + challengeService.addChallengeToPersonalDashboard(email, challengeId, personalDashboardId)); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSaveReqDto.java new file mode 100644 index 00000000..d2c5630b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSaveReqDto.java @@ -0,0 +1,76 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleException; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record ChallengeSaveReqDto( + @NotNull(message = "제목은 필수 입력값입니다.") + String title, + + @NotNull(message = "내용은 필수 입력값입니다.") + String contents, + + @NotNull(message = "카테고리는 필수 입력값입니다.") + Category category, + + @NotNull(message = "주기는 필수 입력값입니다.") + Cycle cycle, + + @NotNull(message = "주기 상세정보는 필수 입력값입니다.") + List cycleDetails, + + @NotNull(message = "시작 날짜는 필수 입력값입니다.") + LocalDate startDate, + + @NotNull(message = "종료 날짜는 필수 입력값입니다.") + LocalDate endDate, + + String representImage +) { + public Challenge toEntity(Member member) { + validateCycleDetails(); + return Challenge.builder() + .status(Status.ACTIVE) + .title(title) + .contents(contents) + .category(category) + .cycle(cycle) + .cycleDetails(cycleDetails) + .startDate(startDate) + .endDate(endDate) + .representImage(representImage) + .member(member) + .build(); + } + + private void validateCycleDetails() { + Set distinctCycleDetails = new HashSet<>(); + + cycleDetails.forEach(cycleDetail -> { + validateCycleDetailUniqueness(distinctCycleDetails, cycleDetail); + validateCycleDetailMatch(cycleDetail); + }); + } + + private void validateCycleDetailUniqueness(Set seenDetails, CycleDetail cycleDetail) { + if (!seenDetails.add(cycleDetail)) { + throw InvalidCycleException.forDuplicateDetail(); + } + } + + private void validateCycleDetailMatch(CycleDetail cycleDetail) { + if (cycleDetail.getCycle() != cycle) { + throw InvalidCycleException.forMismatchDetail(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSearchReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSearchReqDto.java new file mode 100644 index 00000000..9c1657a3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSearchReqDto.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust; + +import lombok.Builder; + +@Builder +public record ChallengeSearchReqDto( + String keyWord +) { + public static ChallengeSearchReqDto from(String keyWord) { + return ChallengeSearchReqDto.builder() + .keyWord(keyWord) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeInfoResDto.java new file mode 100644 index 00000000..f9e7ed49 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeInfoResDto.java @@ -0,0 +1,40 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response; + +import java.time.LocalDate; +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; + +@Builder +public record ChallengeInfoResDto( + Long challengeId, + String title, + String contents, + Category category, + Cycle cycle, + List cycleDetails, + LocalDate startDate, + LocalDate endDate, + String representImage, + String authorName, + String authorProfileImage +) { + public static ChallengeInfoResDto from(Challenge challenge) { + return ChallengeInfoResDto.builder() + .challengeId(challenge.getId()) + .title(challenge.getTitle()) + .contents(challenge.getContents()) + .category(challenge.getCategory()) + .cycle(challenge.getCycle()) + .cycleDetails(challenge.getCycleDetails()) + .startDate(challenge.getStartDate()) + .endDate(challenge.getEndDate()) + .representImage(challenge.getRepresentImage()) + .authorName(challenge.getMember().getNickname()) + .authorProfileImage(challenge.getMember().getPicture()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeListResDto.java new file mode 100644 index 00000000..929d6298 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeListResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record ChallengeListResDto( + List challengeInfoResDto, + PageInfoResDto pageInfoResDto +) { + public static ChallengeListResDto of(List challengeInfoResDtoList, + PageInfoResDto pageInfoResDto) { + return ChallengeListResDto.builder() + .challengeInfoResDto(challengeInfoResDtoList) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeService.java new file mode 100644 index 00000000..928eea11 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeService.java @@ -0,0 +1,192 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.util.ChallengeBlockStatusUtil; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.repository.ChallengeRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.ChallengeAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.ChallengeNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@Service +@RequiredArgsConstructor +public class ChallengeService { + + private static final String CHALLENGE_JOIN_MESSAGE = "%s님이 챌린지에 참여했습니다"; + + + private final ChallengeRepository challengeRepository; + private final MemberRepository memberRepository; + private final PersonalDashboardRepository personalDashboardRepository; + private final BlockRepository blockRepository; + private final NotificationService notificationService; + + @Transactional + public ChallengeInfoResDto save(String email, ChallengeSaveReqDto challengeSaveReqDto) { + Member member = findMemberByEmail(email); + Challenge challenge = challengeSaveReqDto.toEntity(member); + + challengeRepository.save(challenge); + + return ChallengeInfoResDto.from(challenge); + } + + @Transactional + public ChallengeInfoResDto update(String email, Long challengeId, ChallengeSaveReqDto challengeSaveReqDto) { + Member member = findMemberByEmail(email); + Challenge challenge = findChallengeById(challengeId); + verifyMemberIsAuthor(challenge, member); + + challenge.update(challengeSaveReqDto.title(), + challengeSaveReqDto.contents(), + challengeSaveReqDto.cycleDetails(), + challengeSaveReqDto.startDate(), + challengeSaveReqDto.endDate(), + challengeSaveReqDto.representImage()); + + return ChallengeInfoResDto.from(challenge); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findAllChallenges(Pageable pageable) { + Page challenges = challengeRepository.findAllChallenges(pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .toList(); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findChallengesByKeyWord(ChallengeSearchReqDto challengeSearchReqDto, + Pageable pageable) { + Page challenges = challengeRepository.findChallengesByKeyWord(challengeSearchReqDto, pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .toList(); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findByCategory(String category, Pageable pageable) { + Page challenges = challengeRepository.findChallengesByCategory(category, pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .toList(); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + @Transactional(readOnly = true) + public ChallengeInfoResDto findById(Long challengeId) { + Challenge challenge = findChallengeById(challengeId); + + return ChallengeInfoResDto.from(challenge); + } + + @Transactional + public void delete(String email, Long challengeId) { + Member member = findMemberByEmail(email); + Challenge challenge = findChallengeById(challengeId); + verifyMemberIsAuthor(challenge, member); + + challenge.updateStatus(); + } + + @Transactional + public BlockInfoResDto addChallengeToPersonalDashboard(String email, Long personalDashboardId, Long challengeId) { + Member member = findMemberByEmail(email); + Challenge challenge = findChallengeById(challengeId); + Dashboard personalDashboard = personalDashboardRepository.findById(personalDashboardId) + .orElseThrow(DashboardNotFoundException::new); + + Block block = createBlock(challenge, member, personalDashboard); + updateBlockStatusIfNotActive(block, challenge); + + blockRepository.save(block); + + String message = String.format(CHALLENGE_JOIN_MESSAGE, member.getName()); + notificationService.sendNotification(challenge.getMember(), message); + + return BlockInfoResDto.from(block); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findChallengeForMemberId(String email, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + + Page challenges = challengeRepository.findChallengesByEmail(member, pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .collect(Collectors.toList()); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + private Block createBlock(Challenge challenge, Member member, Dashboard personalDashboard) { + return Block.builder() + .title(challenge.getTitle()) + .contents(challenge.getContents()) + .progress(Progress.NOT_STARTED) + .type(Type.CHALLENGE) + .deadLine(LocalDate.now() + .format(DateTimeFormatter.ofPattern("yyyy.MM.dd 23:59"))) + .member(member) + .dashboard(personalDashboard) + .challenge(challenge) + .build(); + } + + private void updateBlockStatusIfNotActive(Block block, Challenge challenge) { + if (!ChallengeBlockStatusUtil.isChallengeBlockActiveToday(challenge.getCycle(), challenge.getCycleDetails())) { + block.updateChallengeStatus(Status.UN_ACTIVE); + } + } + + private Member findMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + } + + private Challenge findChallengeById(Long challengeId) { + return challengeRepository.findById(challengeId) + .orElseThrow(ChallengeNotFoundException::new); + } + + + private void verifyMemberIsAuthor(Challenge challenge, Member member) { + if (!challenge.getMember().equals(member)) { + throw new ChallengeAccessDeniedException(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUpdateScheduler.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUpdateScheduler.java new file mode 100644 index 00000000..4a5ff792 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUpdateScheduler.java @@ -0,0 +1,51 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application.util; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@Component +@RequiredArgsConstructor +public class ChallengeBlockStatusUpdateScheduler { + + private static final String DAILY_CRON_EXPRESSION = "0 0 0 * * ?"; + private static final String CHALLENGE_CREATED_MESSAGE_TEMPLATE = "%s 챌린지가 생성되었습니다"; + + private final BlockRepository blockRepository; + private final NotificationService notificationService; + + @Scheduled(cron = DAILY_CRON_EXPRESSION) + @Transactional + public void updateStatuses() { + List blocks = blockRepository.findByType(Type.CHALLENGE); + + blocks.forEach(block -> { + Status previousStatus = block.getStatus(); + Status newStatus; + + if (!ChallengeBlockStatusUtil.isChallengeBlockActiveToday(block.getChallenge().getCycle(), + block.getChallenge().getCycleDetails())) { + newStatus = Status.UN_ACTIVE; + } else { + newStatus = Status.ACTIVE; + } + + if (newStatus == Status.ACTIVE && previousStatus != Status.ACTIVE) { + block.updateChallengeStatus(newStatus); + + Member member = block.getMember(); + String message = String.format(CHALLENGE_CREATED_MESSAGE_TEMPLATE, block.getChallenge().getTitle()); + + notificationService.sendNotification(member, message); + } + }); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtil.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtil.java new file mode 100644 index 00000000..8f6b8374 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtil.java @@ -0,0 +1,29 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application.util; + +import java.time.LocalDate; +import java.util.List; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; + +public class ChallengeBlockStatusUtil { + public static Boolean isChallengeBlockActiveToday(Cycle cycle, List cycleDetails) { + LocalDate today = LocalDate.now(); + int dayOfWeekNumber = today.getDayOfWeek().getValue(); + + return switch (cycle) { + case DAILY -> true; + case WEEKLY -> { + List activeDays = cycleDetails.stream() + .map(CycleDetail::getValue) + .toList(); + yield activeDays.contains(dayOfWeekNumber); + } + case MONTHLY -> { + List activeDays = cycleDetails.stream() + .map(CycleDetail::getValue) + .toList(); + yield activeDays.contains(today.getDayOfMonth()); + } + }; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverter.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverter.java new file mode 100644 index 00000000..0339e28c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverter.java @@ -0,0 +1,36 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleDetailsConversionException; + +@Converter(autoApply = true) +public class CycleDetailsConverter implements AttributeConverter, String> { + + private final ObjectMapper mapper = new ObjectMapper(); + private final CollectionType listType = mapper.getTypeFactory() + .constructCollectionType(List.class, CycleDetail.class); + + @Override + public String convertToDatabaseColumn(List cycleDetails) { + try { + return mapper.writeValueAsString(cycleDetails); + } catch (JsonProcessingException e) { + throw InvalidCycleDetailsConversionException.forConversionToDatabaseColumn(); + } + } + + @Override + public List convertToEntityAttribute(String data) { + try { + return mapper.readValue(data, listType); + } catch (JsonProcessingException e) { + throw InvalidCycleDetailsConversionException.forConversionToEntityAttribute(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Category.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Category.java new file mode 100644 index 00000000..35ee3d6c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Category.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +public enum Category { + + HEALTH_AND_FITNESS("건강 및 운동"), + MENTAL_WELLNESS("정신 및 마음관리"), + PRODUCTIVITY_AND_TIME_MANAGEMENT("생산성 및 시간 관리"), + FINANCE_AND_ASSET_MANAGEMENT("재정 및 자산 관리"), + SELF_DEVELOPMENT("자기 개발"), + LIFE_ORGANIZATION_AND_MANAGEMENT("생활 정리 및 관리"), + SOCIAL_CONNECTIONS("사회적 연결"), + CREATIVITY_AND_ARTS("창의력 및 예술 활동"), + OTHERS("기타"); + + Category(String description) { + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Challenge.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Challenge.java new file mode 100644 index 00000000..cad6b03f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Challenge.java @@ -0,0 +1,105 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.challenge.converter.CycleDetailsConverter; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Challenge extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @Column(columnDefinition = "TEXT") + private String contents; + + @Enumerated(value = EnumType.STRING) + private Category category; + + private Cycle cycle; + + @Convert(converter = CycleDetailsConverter.class) + @Column(name = "cycle_details") + private List cycleDetails; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "represent_image") + private String representImage; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private Challenge(Status status, + String title, + String contents, + Category category, + Cycle cycle, + List cycleDetails, + LocalDate startDate, + LocalDate endDate, + String representImage, + Member member) { + this.status = status; + this.title = title; + this.contents = contents; + this.category = category; + this.cycle = cycle; + this.cycleDetails = cycleDetails; + this.startDate = startDate; + this.endDate = endDate; + this.representImage = representImage; + this.member = member; + } + + public void update(String updateTitle, String updateContents, List updateCycleDetails, + LocalDate updateStartDate, LocalDate updateEndDate, String updateRepresentImage) { + if (hasChanges(updateTitle, updateContents, updateCycleDetails, updateStartDate, updateEndDate, + updateRepresentImage)) { + this.title = updateTitle; + this.contents = updateContents; + this.cycleDetails = updateCycleDetails; + this.startDate = updateStartDate; + this.endDate = updateEndDate; + this.representImage = updateRepresentImage; + } + } + + private boolean hasChanges(String updateTitle, String updateContents, List updateCycleDetails, + LocalDate updateStartDate, LocalDate updateEndDate, String updateRepresentImage) { + return !this.title.equals(updateTitle) || + !this.contents.equals(updateContents) || + !this.cycleDetails.equals(updateCycleDetails) || + !this.startDate.equals(updateStartDate) || + !this.endDate.equals(updateEndDate) || + !this.representImage.equals(updateRepresentImage); + } + + public void updateStatus() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Cycle.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Cycle.java new file mode 100644 index 00000000..d996c168 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Cycle.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import lombok.Getter; + +@Getter +public enum Cycle { + DAILY("매일"), + WEEKLY("매주"), + MONTHLY("매달"); + + private final String description; + + Cycle(String description) { + this.description = description; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/CycleDetail.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/CycleDetail.java new file mode 100644 index 00000000..af30aaaf --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/CycleDetail.java @@ -0,0 +1,40 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import lombok.Getter; + +@Getter +public enum CycleDetail { + DAILY("매일", Cycle.DAILY, 0), + + MON("월요일", Cycle.WEEKLY, 1), TUE("화요일", Cycle.WEEKLY, 2), WED("수요일", Cycle.WEEKLY, 3), + THU("목요일", Cycle.WEEKLY, 4), FRI("금요일", Cycle.WEEKLY, 5), SAT("토요일", Cycle.WEEKLY, 6), + SUN("일요일", Cycle.WEEKLY, 7), + + FIRST("1일", Cycle.MONTHLY, 1), SECOND("2일", Cycle.MONTHLY, 2), THIRD("3일", Cycle.MONTHLY, 3), + FOURTH("4일", Cycle.MONTHLY, 4), FIFTH("5일", Cycle.MONTHLY, 5), SIXTH("6일", Cycle.MONTHLY, 6), + SEVENTH("7일", Cycle.MONTHLY, 7), EIGHTH("8일", Cycle.MONTHLY, 8), NINTH("9일", Cycle.MONTHLY, 9), + TENTH("10일", Cycle.MONTHLY, 10), ELEVENTH("11일", Cycle.MONTHLY, 11), TWELFTH("12일", Cycle.MONTHLY, 12), + THIRTEENTH("13일", Cycle.MONTHLY, 13), FOURTEENTH("14일", Cycle.MONTHLY, 14), FIFTEENTH("15일", Cycle.MONTHLY, + 15), + SIXTEENTH("16일", Cycle.MONTHLY, 16), SEVENTEENTH("17일", Cycle.MONTHLY, 17), EIGHTEENTH("18일", Cycle.MONTHLY, + 18), + NINETEENTH("19일", Cycle.MONTHLY, 19), TWENTIETH("20일", Cycle.MONTHLY, 20), TWENTY_FIRST("21일", Cycle.MONTHLY, + 21), + TWENTY_SECOND("22일", Cycle.MONTHLY, 22), TWENTY_THIRD("23일", Cycle.MONTHLY, + 23), TWENTY_FOURTH("24일", Cycle.MONTHLY, 24), + TWENTY_FIFTH("25일", Cycle.MONTHLY, 25), TWENTY_SIXTH("26일", Cycle.MONTHLY, + 26), TWENTY_SEVENTH("27일", Cycle.MONTHLY, 27), + TWENTY_EIGHTH("28일", Cycle.MONTHLY, 28), TWENTY_NINTH("29일", Cycle.MONTHLY, + 29), THIRTIETH("30일", Cycle.MONTHLY, 30), + THIRTY_FIRST("31일", Cycle.MONTHLY, 31); + + private final String description; + private final Cycle cycle; + private final int value; + + CycleDetail(String description, Cycle cycle, int value) { + this.description = description; + this.cycle = cycle; + this.value = value; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepository.java new file mode 100644 index 00000000..3fe3e11e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepository.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public interface ChallengeCustomRepository { + Page findAllChallenges(Pageable pageable); + + Page findChallengesByKeyWord(ChallengeSearchReqDto challengeSearchReqDto, Pageable pageable); + + Page findChallengesByEmail(Member member, Pageable pageable); + + Page findChallengesByCategory(String category, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepositoryImpl.java new file mode 100644 index 00000000..ff6281fd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepositoryImpl.java @@ -0,0 +1,107 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.challenge.domain.QChallenge.challenge; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Repository +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ChallengeCustomRepositoryImpl implements ChallengeCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllChallenges(Pageable pageable) { + long total = Optional.ofNullable( + queryFactory + .select(challenge.count()) + .from(challenge) + .where(challenge.status.eq(Status.ACTIVE)) + .fetchOne() + ).orElse(0L); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.status.eq(Status.ACTIVE)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(challenges, pageable, total); + } + + @Override + public Page findChallengesByKeyWord(ChallengeSearchReqDto challengeSearchReqDto, Pageable pageable) { + long total = Optional.ofNullable( + queryFactory + .select(challenge.count()) + .from(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.title.containsIgnoreCase(challengeSearchReqDto.keyWord())) + .fetchOne() + ).orElse(0L); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.title.containsIgnoreCase(challengeSearchReqDto.keyWord())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(challenges, pageable, total); + } + + @Override + public Page findChallengesByEmail(Member member, Pageable pageable) { + long total = queryFactory + .selectFrom(challenge) + .where(challenge.member.eq(member)) + .stream() + .count(); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.member.eq(member) + .and(challenge.status.eq(Status.ACTIVE))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(challenges, pageable, total); + } + + public Page findChallengesByCategory(String category, Pageable pageable) { + long total = Optional.ofNullable( + queryFactory + .select(challenge.count()) + .from(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.category.eq(Category.valueOf(category))) + .fetchOne() + ).orElse(0L); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.category.eq(Category.valueOf(category))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + return new PageImpl<>(challenges, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeRepository.java new file mode 100644 index 00000000..7fee3fc5 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; + +public interface ChallengeRepository extends JpaRepository, ChallengeCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeAccessDeniedException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeAccessDeniedException.java new file mode 100644 index 00000000..437b4c3e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeAccessDeniedException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AccessDeniedGroupException; + +public class ChallengeAccessDeniedException extends AccessDeniedGroupException { + public ChallengeAccessDeniedException(String message) { + super(message); + } + + public ChallengeAccessDeniedException() { + this("챌린지 작성자가 아닙니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeNotFoundException.java new file mode 100644 index 00000000..4fa22834 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class ChallengeNotFoundException extends NotFoundGroupException { + public ChallengeNotFoundException(String message) { + super(message); + } + + public ChallengeNotFoundException() { + this("존재하지 않는 챌린지입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/CycleDetailsNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/CycleDetailsNotFoundException.java new file mode 100644 index 00000000..a0ed264a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/CycleDetailsNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class CycleDetailsNotFoundException extends NotFoundGroupException { + public CycleDetailsNotFoundException(String message) { + super(message); + } + + public CycleDetailsNotFoundException() { + this("주기 세부정보가 존재하지 않습니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleDetailsConversionException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleDetailsConversionException.java new file mode 100644 index 00000000..d58cc9ca --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleDetailsConversionException.java @@ -0,0 +1,22 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class InvalidCycleDetailsConversionException extends InvalidGroupException { + private static final String CONVERSION_TO_DATABASE_COLUMN_MESSAGE = + "List를 JSON 문자열로 변환하는 중 오류가 발생했습니다."; + private static final String CONVERSION_TO_ENTITY_ATTRIBUTE_MESSAGE = + "JSON 문자열을 List로 변환하는 중 오류가 발생했습니다."; + + public InvalidCycleDetailsConversionException(String message) { + super(message); + } + + public static InvalidCycleDetailsConversionException forConversionToDatabaseColumn() { + return new InvalidCycleDetailsConversionException(CONVERSION_TO_DATABASE_COLUMN_MESSAGE); + } + + public static InvalidCycleDetailsConversionException forConversionToEntityAttribute() { + return new InvalidCycleDetailsConversionException(CONVERSION_TO_ENTITY_ATTRIBUTE_MESSAGE); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleException.java new file mode 100644 index 00000000..48cfbb53 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleException.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class InvalidCycleException extends InvalidGroupException { + private static final String DUPLICATE_DETAIL_MESSAGE = "중복된 주기 세부 항목이 발견되었습니다: %s %s"; + private static final String MISMATCH_DETAIL_MESSAGE = "주기와 일치하지 않는 세부 항목이 있습니다: %s %s"; + + public InvalidCycleException(String message) { + super(message); + } + + public static InvalidCycleException forDuplicateDetail() { + return new InvalidCycleException(DUPLICATE_DETAIL_MESSAGE); + } + + public static InvalidCycleException forMismatchDetail() { + return new InvalidCycleException(MISMATCH_DETAIL_MESSAGE); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/Dashboard.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/Dashboard.java new file mode 100644 index 00000000..3a9d7806 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/Dashboard.java @@ -0,0 +1,66 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@DiscriminatorColumn(name = "dtype") +@Inheritance(strategy = InheritanceType.JOINED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Dashboard extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "dtype", insertable = false, updatable = false) + private String dType; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @OneToMany(mappedBy = "dashboard", cascade = CascadeType.ALL, orphanRemoval = true) + private List blocks = new ArrayList<>(); + + public Dashboard(String title, String description, String dType, Member member) { + this.status = Status.ACTIVE; + this.title = title; + this.description = description; + this.dType = dType; + this.member = member; + } + + public void update(String updateTitle, String updateDescription) { + this.title = updateTitle; + this.description = updateDescription; + } + + public void statusUpdate(Status status) { + this.status = status; + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepository.java new file mode 100644 index 00000000..4329afe8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepository.java @@ -0,0 +1,25 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository; + +import java.util.List; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public interface DashboardCustomRepository { + + List findForPersonalDashboard(Member member); + + Set findCategoriesForDashboard(Member member); + + Page findForTeamDashboard(Member member, Pageable pageable); + + List findForTeamDashboard(Member member); + + List findForMembersByQuery(String query); + + double calculateCompletionPercentage(Long dashboardId); + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepositoryImpl.java new file mode 100644 index 00000000..83a9f615 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepositoryImpl.java @@ -0,0 +1,121 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.block.domain.QBlock.block; +import static shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.QPersonalDashboard.personalDashboard; +import static shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.QTeamDashboard.teamDashboard; +import static shop.kkeujeok.kkeujeokbackend.member.domain.QMember.member; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Repository +@Transactional(readOnly = true) +public class DashboardCustomRepositoryImpl implements DashboardCustomRepository { + + private final JPAQueryFactory queryFactory; + + public DashboardCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public List findForPersonalDashboard(Member member) { + return queryFactory + .selectFrom(personalDashboard) + .where(personalDashboard._super.member.eq(member) + .and(personalDashboard._super.status.eq(Status.ACTIVE))) + .fetch(); + } + + @Override + public Set findCategoriesForDashboard(Member member) { + return queryFactory + .select(personalDashboard.category) + .from(personalDashboard) + .where(personalDashboard._super.member.eq(member) + .and(personalDashboard._super.status.eq(Status.ACTIVE))) + .stream() + .collect(Collectors.toSet()); + } + + @Override + public Page findForTeamDashboard(Member member, Pageable pageable) { + long total = queryFactory + .selectFrom(teamDashboard) + .where(teamDashboard._super.member.eq(member)) + .stream() + .count(); + + List dashboards = queryFactory + .selectFrom(teamDashboard) + .where(teamDashboard._super.member.eq(member) + .and(teamDashboard._super.status.eq(Status.ACTIVE))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(dashboards, pageable, total); + } + + @Override + public List findForTeamDashboard(Member member) { + return queryFactory + .selectFrom(teamDashboard) + .where(teamDashboard._super.member.eq(member) + .and(teamDashboard._super.status.eq(Status.ACTIVE))) + .fetch(); + } + + @Override + public List findForMembersByQuery(String query) { + if (query.contains("#")) { + String[] parts = query.split("#"); + String nickname = parts[0]; + String tag = "#" + parts[1]; + + return queryFactory + .selectFrom(member) + .where(member.nickname.eq(nickname) + .and(member.tag.eq(tag))) + .fetch(); + } + + return queryFactory + .selectFrom(member) + .where(member.email.eq(query)) + .fetch(); + } + + @Override + public double calculateCompletionPercentage(Long dashboardId) { + List blocks = queryFactory + .selectFrom(block) + .where(block.dashboard.id.eq(dashboardId)) + .fetch(); + + long totalBlocks = blocks.size(); + + long completedBlocks = blocks.stream() + .filter(b -> b.getProgress().equals(Progress.COMPLETED)) + .count(); + + if (totalBlocks == 0) { + return 0; + } + + return (double) completedBlocks / totalBlocks * 100; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardRepository.java new file mode 100644 index 00000000..33192ed8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; + +public interface DashboardRepository extends JpaRepository, DashboardCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/DashboardNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/DashboardNotFoundException.java new file mode 100644 index 00000000..731f76f8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/DashboardNotFoundException.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class DashboardNotFoundException extends NotFoundGroupException { + + public DashboardNotFoundException(String message) { + super(message); + } + + public DashboardNotFoundException() { + this("존재하지 않는 대시보드 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/InvalidMemberInviteException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/InvalidMemberInviteException.java new file mode 100644 index 00000000..f0f26d04 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/InvalidMemberInviteException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class InvalidMemberInviteException extends InvalidGroupException { + public InvalidMemberInviteException(String message) { + super(message); + } + + public InvalidMemberInviteException() { + this("같은 계정을 초대할 수 없습니다"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/UnauthorizedAccessException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/UnauthorizedAccessException.java new file mode 100644 index 00000000..715f7def --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/UnauthorizedAccessException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AccessDeniedGroupException; + +public class UnauthorizedAccessException extends AccessDeniedGroupException { + public UnauthorizedAccessException(String message) { + super(message); + } + + public UnauthorizedAccessException() { + this("대시보드에 접근할 수 있는 권한이 없습니다"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardController.java new file mode 100644 index 00000000..befd6950 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardController.java @@ -0,0 +1,74 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.application.PersonalDashboardService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/dashboards/personal") +public class PersonalDashboardController { + + private final PersonalDashboardService personalDashboardService; + + @PostMapping("/") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody @Valid PersonalDashboardSaveReqDto personalDashboardSaveReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 생성", + personalDashboardService.save(email, personalDashboardSaveReqDto)); + } + + @PatchMapping("/{dashboardId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId, + @RequestBody PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 수정", + personalDashboardService.update(email, dashboardId, personalDashboardUpdateReqDto)); + } + + @GetMapping("/") + public RspTemplate findForPersonalDashboard(@CurrentUserEmail String email) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 전체 조회", + personalDashboardService.findForPersonalDashboard(email)); + } + + @GetMapping("/{dashboardId}") + public RspTemplate findById(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + return new RspTemplate<>(HttpStatus.OK, "개인 대시보드 상세보기", personalDashboardService.findById(email, dashboardId)); + } + + @GetMapping("/categories") + public RspTemplate findCategoriesForDashboard(@CurrentUserEmail String email) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 카테고리 조회", + personalDashboardService.findCategoriesForDashboard(email)); + } + + @DeleteMapping("/{dashboardId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + personalDashboardService.delete(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "개인 대시보드 삭제, 복구"); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardSaveReqDto.java new file mode 100644 index 00000000..818f8ae0 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardSaveReqDto.java @@ -0,0 +1,29 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record PersonalDashboardSaveReqDto( + @NotBlank(message = "필수 입력값 입니다.") + String title, + + @NotBlank(message = "필수 입력값 입니다.") + @Size(max = 300) + String description, + + boolean isPublic, + + String category +) { + public PersonalDashboard toEntity(Member member) { + return PersonalDashboard.builder() + .title(title) + .description(description) + .member(member) + .isPublic(isPublic) + .category(category) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardUpdateReqDto.java new file mode 100644 index 00000000..576ef873 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardUpdateReqDto.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request; + +public record PersonalDashboardUpdateReqDto( + String title, + String description, + boolean isPublic, + String category +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardCategoriesResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardCategoriesResDto.java new file mode 100644 index 00000000..bbf8451c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardCategoriesResDto.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response; + +import java.util.List; +import java.util.Set; +import lombok.Builder; + +@Builder +public record PersonalDashboardCategoriesResDto( + Set categories +) { + public static PersonalDashboardCategoriesResDto from(Set categories) { + return PersonalDashboardCategoriesResDto.builder() + .categories(categories) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardInfoResDto.java new file mode 100644 index 00000000..f648b431 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardInfoResDto.java @@ -0,0 +1,43 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record PersonalDashboardInfoResDto( + Long dashboardId, + Long myId, + Long creatorId, + String title, + String description, + boolean isPublic, + String category, + double blockProgress +) { + public static PersonalDashboardInfoResDto of(Member member, PersonalDashboard dashboard) { + return commonBuilder(member, dashboard) + .build(); + } + + public static PersonalDashboardInfoResDto detailOf(Member member, + PersonalDashboard dashboard, + double blockProgress) { + return commonBuilder(member, dashboard) + .blockProgress(blockProgress) + .build(); + } + + private static PersonalDashboardInfoResDtoBuilder commonBuilder(Member member, + PersonalDashboard dashboard) { + return PersonalDashboardInfoResDto.builder() + .dashboardId(dashboard.getId()) + .myId(member.getId()) + .creatorId(dashboard.getMember().getId()) + .title(dashboard.getTitle()) + .description(dashboard.getDescription()) + .isPublic(dashboard.isPublic()) + .category(dashboard.getCategory()); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardListResDto.java new file mode 100644 index 00000000..de0e4c9a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardListResDto.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public record PersonalDashboardListResDto( + List personalDashboardListResDto +) { + public static PersonalDashboardListResDto of(List personalDashboards) { + return PersonalDashboardListResDto.builder() + .personalDashboardListResDto(personalDashboards) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardService.java new file mode 100644 index 00000000..a3e643ae --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardService.java @@ -0,0 +1,118 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.application; + +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.UnauthorizedAccessException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PersonalDashboardService { + + private final PersonalDashboardRepository personalDashboardRepository; + private final MemberRepository memberRepository; + + // 개인 대시보드 저장 + @Transactional + public PersonalDashboardInfoResDto save(String email, PersonalDashboardSaveReqDto personalDashboardSaveReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardSaveReqDto.toEntity(member); + + personalDashboardRepository.save(dashboard); + + return PersonalDashboardInfoResDto.of(member, dashboard); + } + + // 개인 대시보드 수정 + @Transactional + public PersonalDashboardInfoResDto update(String email, + Long dashboardId, + PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.update(personalDashboardUpdateReqDto.title(), + personalDashboardUpdateReqDto.description(), + personalDashboardUpdateReqDto.isPublic(), + personalDashboardUpdateReqDto.category()); + + return PersonalDashboardInfoResDto.of(member, dashboard); + } + + // 개인 대시보드 전체 조회 + public PersonalDashboardListResDto findForPersonalDashboard(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + List personalDashboards = personalDashboardRepository.findForPersonalDashboard(member); + + List personalDashboardInfoResDtoList = personalDashboards.stream() + .map(p -> PersonalDashboardInfoResDto.of(member, p)) + .toList(); + + return PersonalDashboardListResDto.of(personalDashboardInfoResDtoList); + } + + // 개인 대시보드 상세조회 + public PersonalDashboardInfoResDto findById(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + validateDashboardAccess(dashboard, member); + + double blockProgress = personalDashboardRepository.calculateCompletionPercentage(dashboard.getId()); + + return PersonalDashboardInfoResDto.detailOf(member, dashboard, blockProgress); + } + + private void validateDashboardAccess(PersonalDashboard dashboard, Member member) { + if (!dashboard.getMember().equals(member)) { + throw new UnauthorizedAccessException(); + } + } + + // 개인 대시보드 카테고리 조회 + public PersonalDashboardCategoriesResDto findCategoriesForDashboard(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + + Set categories = personalDashboardRepository.findCategoriesForDashboard(member); + + return PersonalDashboardCategoriesResDto.from(categories); + } + + // 개인 대시보드 삭제 유무 업데이트 (논리 삭제) + @Transactional + public void delete(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.statusUpdate(); + } + + private void verifyMemberIsAuthor(PersonalDashboard dashboard, Member member) { + if (!member.equals(dashboard.getMember())) { + throw new DashboardAccessDeniedException(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboard.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboard.java new file mode 100644 index 00000000..a86fcf9b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboard.java @@ -0,0 +1,39 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain; + +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PersonalDashboard extends Dashboard { + + public boolean isPublic; + + private String category; + + @Builder + private PersonalDashboard(String title, String description, String dType, Member member, boolean isPublic, + String category) { + super(title, description, dType, member); + this.category = category; + this.isPublic = isPublic; + } + + public void update(String updateTitle, String updateDescription, boolean updateIsPublic, String updateCategory) { + super.update(updateTitle, updateDescription); + this.isPublic = updateIsPublic; + this.category = updateCategory; + } + + public void statusUpdate() { + super.statusUpdate((super.getStatus() == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepository.java new file mode 100644 index 00000000..b1e5259d --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepository.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardCustomRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; + +public interface PersonalDashboardRepository extends JpaRepository, DashboardCustomRepository { + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/exception/DashboardAccessDeniedException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/exception/DashboardAccessDeniedException.java new file mode 100644 index 00000000..b3263078 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/exception/DashboardAccessDeniedException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AccessDeniedGroupException; + +public class DashboardAccessDeniedException extends AccessDeniedGroupException { + public DashboardAccessDeniedException(String message) { + super(message); + } + + public DashboardAccessDeniedException() { + this("대시보드 생성자가 아닙니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardController.java new file mode 100644 index 00000000..c1d99a04 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardController.java @@ -0,0 +1,86 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/dashboards/team") +public class TeamDashboardController { + + private final TeamDashboardService teamDashboardService; + + @PostMapping("/") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody @Valid TeamDashboardSaveReqDto teamDashboardSaveReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "팀 대시보드 생성", + teamDashboardService.save(email, teamDashboardSaveReqDto)); + } + + @PatchMapping("/{dashboardId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId, + @RequestBody @Valid TeamDashboardUpdateReqDto teamDashboardUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "팀 대시보드 수정", + teamDashboardService.update(email, dashboardId, teamDashboardUpdateReqDto)); + } + + @GetMapping("/") + public RspTemplate findForTeamDashboard(@CurrentUserEmail String email) { + return new RspTemplate<>(HttpStatus.OK, + "팀 대시보드 전체 조회", + teamDashboardService.findForTeamDashboard(email)); + } + + @GetMapping("/{dashboardId}") + public RspTemplate findById(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + return new RspTemplate<>(HttpStatus.OK, "팀 대시보드 상세보기", teamDashboardService.findById(email, dashboardId)); + } + + @DeleteMapping("/{dashboardId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + teamDashboardService.delete(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "팀 대시보드 삭제, 복구"); + } + + @PostMapping("/{dashboardId}/join") + public RspTemplate joinTeam(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + teamDashboardService.joinTeam(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "팀 가입"); + } + + @PostMapping("/{dashboardId}/leave") + public RspTemplate leaveTeam(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + teamDashboardService.leaveTeam(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "팀 탈퇴"); + } + + @GetMapping("/search") + public RspTemplate search(@RequestParam(name = "query") String query) { + return new RspTemplate<>(HttpStatus.OK, "팀원 초대 리스트", teamDashboardService.searchMembers(query)); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardSaveReqDto.java new file mode 100644 index 00000000..f6ae1e8a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardSaveReqDto.java @@ -0,0 +1,25 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.List; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record TeamDashboardSaveReqDto( + @NotBlank(message = "필수 입력값 입니다.") + String title, + + @NotBlank(message = "필수 입력값 입니다.") + @Size(max = 300) + String description, + List invitedEmails +) { + public TeamDashboard toEntity(Member member) { + return TeamDashboard.builder() + .title(title) + .description(description) + .member(member) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardUpdateReqDto.java new file mode 100644 index 00000000..33e6da73 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardUpdateReqDto.java @@ -0,0 +1,11 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request; + +import java.util.List; + +public record TeamDashboardUpdateReqDto( + String title, + String description, + List invitedEmails + +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/SearchMemberListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/SearchMemberListResDto.java new file mode 100644 index 00000000..b4318819 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/SearchMemberListResDto.java @@ -0,0 +1,34 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record SearchMemberListResDto( + List searchMembers +) { + public static SearchMemberListResDto from(List members) { + return SearchMemberListResDto.builder() + .searchMembers(members.stream() + .map(SearchMemberInfoResDto::from) + .toList()) + .build(); + } + + @Builder + private record SearchMemberInfoResDto( + Long id, + String picture, + String email + ) { + private static SearchMemberInfoResDto from(Member member) { + return SearchMemberInfoResDto.builder() + .id(member.getId()) + .picture(member.getPicture()) + .email(member.getEmail()) + .build(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardInfoResDto.java new file mode 100644 index 00000000..cdf94b61 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardInfoResDto.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@Builder +public record TeamDashboardInfoResDto( + Long dashboardId, + Long myId, + Long creatorId, + String title, + String description, + double blockProgress, + List joinMembers +) { + public static TeamDashboardInfoResDto of(Member member, TeamDashboard dashboard) { + return commonBuilder(member, dashboard) + .build(); + } + + public static TeamDashboardInfoResDto detailOf(Member member, TeamDashboard dashboard, double blockProgress) { + return commonBuilder(member, dashboard) + .blockProgress(blockProgress) + .joinMembers(dashboard.getTeamDashboardMemberMappings().stream() + .map(teamDashboardMemberMapping -> { + return JoinMemberInfoResDto.from(teamDashboardMemberMapping.getMember()); + }) + .toList()) + .build(); + } + + public static TeamDashboardInfoResDtoBuilder commonBuilder(Member member, TeamDashboard dashboard) { + return TeamDashboardInfoResDto.builder() + .dashboardId(dashboard.getId()) + .myId(member.getId()) + .creatorId(dashboard.getMember().getId()) + .title(dashboard.getTitle()) + .description(dashboard.getDescription()); + } + + @Builder + private record JoinMemberInfoResDto( + String picture, + String email, + String name, + String nickName, + SocialType socialType, + String introduction + ) { + private static JoinMemberInfoResDto from(Member member) { + return JoinMemberInfoResDto.builder() + .picture(member.getPicture()) + .email(member.getEmail()) + .name(member.getName()) + .nickName(member.getNickname()) + .socialType(member.getSocialType()) + .introduction(member.getIntroduction()) + .build(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardListResDto.java new file mode 100644 index 00000000..d05086ad --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardListResDto.java @@ -0,0 +1,25 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record TeamDashboardListResDto( + List teamDashboardInfoResDto, + PageInfoResDto pageInfoResDto +) { + public static TeamDashboardListResDto of(List teamDashboards, + PageInfoResDto pageInfoResDto) { + return TeamDashboardListResDto.builder() + .teamDashboardInfoResDto(teamDashboards) + .pageInfoResDto(pageInfoResDto) + .build(); + } + + public static TeamDashboardListResDto from(List teamDashboards) { + return TeamDashboardListResDto.builder() + .teamDashboardInfoResDto(teamDashboards) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardService.java new file mode 100644 index 00000000..11e3e85b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardService.java @@ -0,0 +1,177 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.InvalidMemberInviteException; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.UnauthorizedAccessException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamDashboardService { + + private static final String TEAM_DASHBOARD_JOIN_MESSAGE = "%s님이 %s 대시보드에 초대하였습니다."; + + + private final TeamDashboardRepository teamDashboardRepository; + private final MemberRepository memberRepository; + private final NotificationService notificationService; + + // 팀 대시보드 저장 + @Transactional + public TeamDashboardInfoResDto save(String email, TeamDashboardSaveReqDto teamDashboardSaveReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard teamDashboard = teamDashboardSaveReqDto.toEntity(member); + + teamDashboardRepository.save(teamDashboard); + + inviteMember(member, teamDashboard, teamDashboardSaveReqDto.invitedEmails()); + return TeamDashboardInfoResDto.of(member, teamDashboard); + } + + // 팀 대시보드 수정 + @Transactional + public TeamDashboardInfoResDto update(String email, + Long dashboardId, + TeamDashboardUpdateReqDto teamDashboardUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.update(teamDashboardUpdateReqDto.title(), + teamDashboardUpdateReqDto.description()); + + inviteMember(member, dashboard, teamDashboardUpdateReqDto.invitedEmails()); + + return TeamDashboardInfoResDto.of(member, dashboard); + } + + // 팀 대시보드 전체 조회(페이지네이션) + public TeamDashboardListResDto findForTeamDashboard(String email, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Page teamDashboards = teamDashboardRepository.findForTeamDashboard(member, pageable); + + List teamDashboardInfoResDtoList = teamDashboards.stream() + .map(t -> TeamDashboardInfoResDto.of(member, t)) + .toList(); + + return TeamDashboardListResDto + .of(teamDashboardInfoResDtoList, PageInfoResDto.from(teamDashboards)); + } + + // 팀 대시보드 전체 조회(페이지네이션 X) + public TeamDashboardListResDto findForTeamDashboard(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + List teamDashboards = teamDashboardRepository.findForTeamDashboard(member); + + List teamDashboardInfoResDtoList = teamDashboards.stream() + .map(t -> TeamDashboardInfoResDto.of(member, t)) + .toList(); + + return TeamDashboardListResDto.from(teamDashboardInfoResDtoList); + } + + // 팀 대시보드 상세 조회 + public TeamDashboardInfoResDto findById(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + validateDashboardAccess(dashboard, member); + + double blockProgress = teamDashboardRepository.calculateCompletionPercentage(dashboard.getId()); + + return TeamDashboardInfoResDto.detailOf(member, dashboard, blockProgress); + } + + private void validateDashboardAccess(TeamDashboard dashboard, Member member) { + boolean isMemberInDashboard = dashboard.getTeamDashboardMemberMappings().stream() + .anyMatch(mapping -> mapping.getMember().equals(member)); + + if (!dashboard.getMember().equals(member) && !isMemberInDashboard) { + throw new UnauthorizedAccessException(); + } + } + + // 팀 대시보드 삭제 유무 업데이트 (논리 삭제) + @Transactional + public void delete(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.statusUpdate(); + } + + @Transactional + public void joinTeam(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + dashboard.addMember(member); + } + + @Transactional + public void leaveTeam(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + dashboard.removeMember(member); + } + + public SearchMemberListResDto searchMembers(String query) { + List searchMembers = teamDashboardRepository.findForMembersByQuery(query); + + return SearchMemberListResDto.from(searchMembers); + } + + private void inviteMember(Member member, TeamDashboard teamDashboard, List invitedEmails) { + for (String email : invitedEmails) { + verifyIsSameEmail(member.getEmail(), email); + + Member inviteReceivedMember = memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + + String message = String.format(TEAM_DASHBOARD_JOIN_MESSAGE, member.getName(), + teamDashboard.getTitle()); + notificationService.sendNotification(inviteReceivedMember, message); + } + } + + private void verifyIsSameEmail(String email, String otherEmail) { + if (email.equals(otherEmail)) { + throw new InvalidMemberInviteException(); + } + } + + private void verifyMemberIsAuthor(TeamDashboard teamDashboard, Member member) { + if (!member.equals(teamDashboard.getMember())) { + throw new DashboardAccessDeniedException(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboard.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboard.java new file mode 100644 index 00000000..d5d76249 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboard.java @@ -0,0 +1,53 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamDashboard extends Dashboard { + + @OneToMany(mappedBy = "teamDashboard", cascade = CascadeType.ALL, orphanRemoval = true) + private List teamDashboardMemberMappings = new ArrayList<>(); + + @OneToMany(mappedBy = "teamDashboard", cascade = CascadeType.ALL) + private List documents = new ArrayList<>(); + + @Builder + private TeamDashboard(String title, String description, String dType, Member member) { + super(title, description, dType, member); + } + + public void update(String updateTitle, String updateDescription) { + super.update(updateTitle, updateDescription); + } + + public void statusUpdate() { + super.statusUpdate((super.getStatus() == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE); + } + + public void addMember(Member member) { + teamDashboardMemberMappings.add(TeamDashboardMemberMapping.builder() + .teamDashboard(this) + .member(member) + .build()); + } + + public void removeMember(Member member) { + teamDashboardMemberMappings.removeIf(mapping -> mapping.getMember().equals(member)); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardMemberMapping.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardMemberMapping.java new file mode 100644 index 00000000..43053286 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardMemberMapping.java @@ -0,0 +1,32 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamDashboardMemberMapping extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_dashboard_id", nullable = false) + private TeamDashboard teamDashboard; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + private TeamDashboardMemberMapping(TeamDashboard teamDashboard, Member member) { + this.teamDashboard = teamDashboard; + this.member = member; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepository.java new file mode 100644 index 00000000..406befeb --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepository.java @@ -0,0 +1,8 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardCustomRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; + +public interface TeamDashboardRepository extends JpaRepository, DashboardCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/TeamDocumentController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/TeamDocumentController.java new file mode 100644 index 00000000..623675c2 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/TeamDocumentController.java @@ -0,0 +1,73 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.FindTeamDocumentReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.TeamDocumentReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.TeamDocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.FindTeamDocumentResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentDetailResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.application.TeamDocumentService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/dashboards/team/document") +public class TeamDocumentController { + + private final TeamDocumentService teamDocumentService; + + @PostMapping("") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody TeamDocumentReqDto documentReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "팀 문서 생성", + teamDocumentService.save(email, documentReqDto)); + } + + @PatchMapping("/{teamDocumentId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "teamDocumentId") Long teamDocumentId, + @RequestBody TeamDocumentUpdateReqDto teamDocumentUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 문서 수정", teamDocumentService.update(email, teamDocumentId, teamDocumentUpdateReqDto)); + } + + @GetMapping("/{teamDocumentId}") + public RspTemplate findById(@PathVariable(name = "teamDocumentId") Long teamDocumentId) { + return new RspTemplate<>(HttpStatus.OK, "팀 문서 상세보기", teamDocumentService.findById(teamDocumentId)); + } + + @GetMapping("/categories/{teamDashboardId}") + public RspTemplate findCategories(@PathVariable(name = "teamDashboardId") Long teamDashboardId) { + return new RspTemplate<>(HttpStatus.OK, + "팀 문서 카테고리 조회", + teamDocumentService.findTeamDocumentCategory(teamDashboardId)); + } + + // 카테고리 선택해서 그걸로 찾기(쿼리) + @GetMapping("/search/{teamDashboardId}") + public RspTemplate findTeamDocumentByCategory(@PathVariable(name = "teamDashboardId") Long teamDashboardId, + @RequestParam(defaultValue = "", name = "category") String category, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, + "팀 문서 카테고리로 조회", + teamDocumentService.findTeamDocumentByCategory(teamDashboardId, + category, + PageRequest.of(page, size))); + } + + // 팀 문서 삭제 + @DeleteMapping("/{teamDocumentId}") + public RspTemplate delete(@PathVariable(name = "teamDocumentId") Long teamDocumentId) { + teamDocumentService.delete(teamDocumentId); + + return new RspTemplate<>(HttpStatus.OK, "팀 문서 삭제"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/FindTeamDocumentReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/FindTeamDocumentReqDto.java new file mode 100644 index 00000000..628bcc6f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/FindTeamDocumentReqDto.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request; + +import lombok.Builder; + +@Builder +public record FindTeamDocumentReqDto( + String category +) { + public static FindTeamDocumentReqDto of(String category) { + return FindTeamDocumentReqDto.builder() + .category(category) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/TeamDocumentReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/TeamDocumentReqDto.java new file mode 100644 index 00000000..6f967d4f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/TeamDocumentReqDto.java @@ -0,0 +1,24 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request; + +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record TeamDocumentReqDto( + String title, + String content, + String category, + Long teamDashboardId + +) { + public TeamDocument toEntity(Member member, TeamDashboard teamDashboard) { + return TeamDocument.builder() + .author(member.getName()) + .picture(member.getPicture()) + .title(title) + .content(content) + .category(category) + .teamDashboard(teamDashboard) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/TeamDocumentUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/TeamDocumentUpdateReqDto.java new file mode 100644 index 00000000..b418ca0f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/request/TeamDocumentUpdateReqDto.java @@ -0,0 +1,8 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request; + +public record TeamDocumentUpdateReqDto( + String title, + String content, + String category +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/FindTeamDocumentResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/FindTeamDocumentResDto.java new file mode 100644 index 00000000..2126c2ee --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/FindTeamDocumentResDto.java @@ -0,0 +1,21 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response; + +import lombok.Builder; +import org.springframework.data.domain.Page; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Builder +public record FindTeamDocumentResDto( + List teamDocuments, + PageInfoResDto pageInfoResDto +) { + public static FindTeamDocumentResDto from(List teamDocuments, PageInfoResDto pageInfoResDto) { + return FindTeamDocumentResDto.builder() + .teamDocuments(teamDocuments) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentCategoriesResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentCategoriesResDto.java new file mode 100644 index 00000000..b1a0d02c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentCategoriesResDto.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response; + +import lombok.Builder; +import java.util.List; + +@Builder +public record TeamDocumentCategoriesResDto( + List categories +) { + public static TeamDocumentCategoriesResDto of(List categories) { + return TeamDocumentCategoriesResDto.builder() + .categories(categories) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentDetailResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentDetailResDto.java new file mode 100644 index 00000000..77b33e11 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentDetailResDto.java @@ -0,0 +1,26 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; + +@Builder +public record TeamDocumentDetailResDto( + String author, + String picture, + String title, + String content, + String category, + Long teamDocumentId +) { + public static TeamDocumentDetailResDto from(TeamDocument document) { + return TeamDocumentDetailResDto.builder() + .author(document.getAuthor()) + .picture(document.getPicture()) + .title(document.getTitle()) + .content(document.getContent()) + .category(document.getCategory()) + .teamDocumentId(document.getId()) + .build(); + } +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentResDto.java new file mode 100644 index 00000000..ce464c95 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/dto/response/TeamDocumentResDto.java @@ -0,0 +1,23 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; + +@Builder +public record TeamDocumentResDto( + String author, + String picture, + String title, + String category, + Long teamDocumentId +) { + public static TeamDocumentResDto from(TeamDocument document) { + return TeamDocumentResDto.builder() + .author(document.getAuthor()) + .picture(document.getPicture()) + .title(document.getTitle()) + .category(document.getCategory()) + .teamDocumentId(document.getId()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/application/TeamDocumentService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/application/TeamDocumentService.java new file mode 100644 index 00000000..854dfc27 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/application/TeamDocumentService.java @@ -0,0 +1,107 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.FindTeamDocumentReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.TeamDocumentReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.TeamDocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.FindTeamDocumentResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentDetailResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.repository.TeamDocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.exception.TeamDocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamDocumentService { + + private final TeamDocumentRepository teamDocumentRepository; + private final TeamDashboardRepository teamDashboardRepository; + private final MemberRepository memberRepository; + + @Transactional + public TeamDocumentResDto save(String email, TeamDocumentReqDto documentReqDto) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + TeamDashboard teamDashboard = teamDashboardRepository.findById(documentReqDto.teamDashboardId()) + .orElseThrow(DashboardNotFoundException::new); + + TeamDocument document = documentReqDto.toEntity(member, teamDashboard); + + teamDocumentRepository.save(document); + + return TeamDocumentResDto.from(document); + } + + public TeamDocumentDetailResDto findById(Long teamDocumentId) { + TeamDocument teamDocument = teamDocumentRepository.findById(teamDocumentId) + .orElseThrow(TeamDocumentNotFoundException::new); + + return TeamDocumentDetailResDto.from(teamDocument); + } + + @Transactional + public TeamDocumentResDto update(String email, Long teamDocumentId, TeamDocumentUpdateReqDto teamDocumentUpdateReqDto) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + TeamDocument teamDocument = teamDocumentRepository.findById(teamDocumentId) + .orElseThrow(TeamDocumentNotFoundException::new); + + teamDocument.update( + teamDocumentUpdateReqDto.title(), + teamDocumentUpdateReqDto.content(), + teamDocumentUpdateReqDto.category() + ); + + return TeamDocumentResDto.from(teamDocument); + } + + public TeamDocumentCategoriesResDto findTeamDocumentCategory(Long teamDashboardId) { + TeamDashboard teamDashboard = teamDashboardRepository.findById(teamDashboardId) + .orElseThrow(DashboardNotFoundException::new); + + List categories = teamDocumentRepository.findTeamDocumentCategory(teamDashboard); + + return TeamDocumentCategoriesResDto.of(categories); + } + + public FindTeamDocumentResDto findTeamDocumentByCategory(Long teamDashboardId, + String category, + Pageable pageable) { + TeamDashboard teamDashboard = teamDashboardRepository.findById(teamDashboardId) + .orElseThrow(DashboardNotFoundException::new); + + Page teamDocuments = teamDocumentRepository.findForTeamDocumentByCategory(teamDashboard, + category, + pageable); + + List teamDocumentResDtos = teamDocuments.stream() + .map(TeamDocumentResDto::from) + .toList(); + + return FindTeamDocumentResDto.from(teamDocumentResDtos, PageInfoResDto.from(teamDocuments)); + } + + @Transactional + public void delete(Long teamDocumentId) { + TeamDocument teamDocument = teamDocumentRepository.findById(teamDocumentId) + .orElseThrow(TeamDocumentNotFoundException::new); + + teamDocument.statusUpdate(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/TeamDocument.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/TeamDocument.java new file mode 100644 index 00000000..d7639028 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/TeamDocument.java @@ -0,0 +1,59 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamDocument extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String author; + + private String picture; + + private String title; + + private String content; + + private String category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "TeamDashboard_id") + private TeamDashboard TeamDashboard; + + @Builder + private TeamDocument(String author, + String picture, + String title, + String content, + String category, + TeamDashboard teamDashboard) { + this.status = Status.ACTIVE; + this.author = author; + this.picture = picture; + this.title = title; + this.content = content; + this.category = category; + this.TeamDashboard = teamDashboard; + } + + public void update(String updateTitle, String updateContent, String updateCategory) { + this.title = updateTitle; + this.content = updateContent; + this.category = updateCategory; + } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentCustomRepository.java new file mode 100644 index 00000000..52d2657d --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentCustomRepository.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; + +import java.util.List; +import java.util.Set; + +public interface TeamDocumentCustomRepository { + + Page findForTeamDocumentByCategory(TeamDashboard teamDashboard, String category, Pageable pageable); + + List findTeamDocumentCategory(TeamDashboard teamDashboard); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentCustomRepositoryImpl.java new file mode 100644 index 00000000..5f35b3a4 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentCustomRepositoryImpl.java @@ -0,0 +1,71 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageImpl; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.QTeamDocument.teamDocument; + +@Repository +@Transactional(readOnly = true) +public class TeamDocumentCustomRepositoryImpl implements TeamDocumentCustomRepository { + + private final JPAQueryFactory queryFactory; + + public TeamDocumentCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findForTeamDocumentByCategory(TeamDashboard teamDashboard, String category, Pageable pageable) { + List results = queryFactory + .selectFrom(teamDocument) + .where( + teamDocument.TeamDashboard.eq(teamDashboard), + categoryEq(category), + teamDocument.status.eq(Status.ACTIVE) + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = queryFactory + .selectFrom(teamDocument) + .where( + teamDocument.TeamDashboard.eq(teamDashboard), + categoryEq(category), + teamDocument.status.eq(Status.ACTIVE) + ) + .fetchCount(); + + return new PageImpl<>(results, pageable, total); + } + + private BooleanExpression categoryEq(String category) { + return (category == null || category.trim().isEmpty()) ? null : teamDocument.category.eq(category); + } + + @Override + public List findTeamDocumentCategory(TeamDashboard teamDashboard) { + Set uniqueCategories = queryFactory + .select(teamDocument.category) + .from(teamDocument) + .where(teamDocument.TeamDashboard.eq(teamDashboard) + ,teamDocument.status.eq(Status.ACTIVE)) + .stream() + .collect(Collectors.toSet()); + + return uniqueCategories.stream().toList(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentRepository.java new file mode 100644 index 00000000..59e7e4e3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/domain/repository/TeamDocumentRepository.java @@ -0,0 +1,8 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.domain.TeamDocument; + +public interface TeamDocumentRepository extends JpaRepository, TeamDocumentCustomRepository { + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/exception/TeamDocumentNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/exception/TeamDocumentNotFoundException.java new file mode 100644 index 00000000..49f10930 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/exception/TeamDocumentNotFoundException.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class TeamDocumentNotFoundException extends NotFoundGroupException { + + public TeamDocumentNotFoundException(String message) { + super(message); + } + + public TeamDocumentNotFoundException() { + this("존재하지 않는 팀문서 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentController.java new file mode 100644 index 00000000..5c205ea6 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentController.java @@ -0,0 +1,52 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.DocumentService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/documents") +public class DocumentController { + + private final DocumentService documentService; + + @PostMapping("") + public RspTemplate save(@RequestBody DocumentInfoReqDto documentInfoReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 문서 등록", documentService.save(documentInfoReqDto)); + } + + @PatchMapping("/{documentId}") + public RspTemplate update(@PathVariable(name = "documentId") Long documentId, + @RequestBody DocumentUpdateReqDto documentUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 문서 수회", documentService.update(documentId, documentUpdateReqDto)); + } + + // 팀 문서 조회 + @GetMapping("") + public RspTemplate findForDocumentByTeamDashboardId( + @RequestParam(name = "teamDashboardId") Long teamDashboardId, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + return new RspTemplate<>(HttpStatus.OK, + "블록 상태별 전체 조회", + documentService.findDocumentByTeamDashboardId(teamDashboardId, PageRequest.of(page, size))); + } + + // 팀 문서 삭제 + @DeleteMapping("{documentId}") + public RspTemplate delete(@PathVariable(name = "documentId") Long documentId) { + documentService.delete(documentId); + + return new RspTemplate<>(HttpStatus.OK, "팀 문서 삭제"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileController.java new file mode 100644 index 00000000..928f8970 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileController.java @@ -0,0 +1,54 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.FileService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/files") +public class FileController { + + private final FileService fileService; + + @PostMapping("") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody FileInfoReqDto fileInfoReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 파일 등록", fileService.save(email, fileInfoReqDto)); + } + + @PatchMapping("/{fileId}") + public RspTemplate update(@PathVariable(name = "fileId") Long fileId, + @RequestBody FileInfoReqDto fileInfoReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 파일 수정", fileService.update(fileId, fileInfoReqDto)); + } + + @GetMapping("") + public RspTemplate findForFile(@RequestParam(name = "documentId") Long documentId, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + return new RspTemplate<>(HttpStatus.OK, + "팀 문서 파일 조회", + fileService.findForFile(documentId, PageRequest.of(page, size))); + } + + @GetMapping("/{fileId}") + public RspTemplate findById(@PathVariable(name = "fileId") Long fileId) { + return new RspTemplate<>(HttpStatus.OK, "팀 파일 상세보기", fileService.findById(fileId)); + } + + @DeleteMapping("{fileId}") + public RspTemplate delete(@PathVariable(name = "fileId") Long blockId) { + fileService.delete(blockId); + + return new RspTemplate<>(HttpStatus.OK, "팀 파일 삭제"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentInfoReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentInfoReqDto.java new file mode 100644 index 00000000..ded35d63 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentInfoReqDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record DocumentInfoReqDto( + Long teamDashboardId, + String title +) { + public Document toEntity(TeamDashboard teamDashboard) { + return Document.builder() + .title(title) + .teamDashboard(teamDashboard) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentUpdateReqDto.java new file mode 100644 index 00000000..52d880b3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentUpdateReqDto.java @@ -0,0 +1,6 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request; + +public record DocumentUpdateReqDto ( + String title +){ +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/FileInfoReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/FileInfoReqDto.java new file mode 100644 index 00000000..04651cfa --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/FileInfoReqDto.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request; + +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +public record FileInfoReqDto( + Long documentId, + String email, + String title, + String content +) { + public File toEntity(String email, Document document) { + return File.builder() + .email(email) + .title(title) + .content(content) + .document(document) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentInfoResDto.java new file mode 100644 index 00000000..0d8d3502 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentInfoResDto.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; + +@Builder +public record DocumentInfoResDto( + Long documentId, + String title +) { + public static DocumentInfoResDto from(Document document) { + return DocumentInfoResDto.builder() + .documentId(document.getId()) + .title(document.getTitle()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentListResDto.java new file mode 100644 index 00000000..74a68ecb --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentListResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Builder +public record DocumentListResDto( + List documentInfoResDtos, + PageInfoResDto pageInfoResDto +) { + public static DocumentListResDto of(List documentInfoResDtos, PageInfoResDto pageInfoResDto) { + return DocumentListResDto.builder() + .documentInfoResDtos(documentInfoResDtos) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileInfoResDto.java new file mode 100644 index 00000000..f8972695 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileInfoResDto.java @@ -0,0 +1,21 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +@Builder +public record FileInfoResDto( + Long fileId, + String email, + String title, + String content +) { + public static FileInfoResDto from(File file) { + return FileInfoResDto.builder() + .fileId(file.getId()) + .email(file.getEmail()) + .title(file.getTitle()) + .content(file.getContent()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileListResDto.java new file mode 100644 index 00000000..9f14c89f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileListResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Builder +public record FileListResDto( + List fileInfoResDto, + PageInfoResDto pageInfoResDto +) { + public static FileListResDto of(List fileInfoResDto, PageInfoResDto pageInfoResDto) { + return FileListResDto.builder() + .fileInfoResDto(fileInfoResDto) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentService.java new file mode 100644 index 00000000..3bc45ec1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentService.java @@ -0,0 +1,70 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.TeamDashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DocumentService { + + private final DocumentRepository documentRepository; + private final TeamDashboardRepository teamDashboardRepository; + + // 팀 문서 생성 + @Transactional + public DocumentInfoResDto save(DocumentInfoReqDto documentInfoReqDto) { + TeamDashboard teamDashboard = teamDashboardRepository.findById(documentInfoReqDto.teamDashboardId()) + .orElseThrow(TeamDashboardNotFoundException::new); + Document document = documentInfoReqDto.toEntity(teamDashboard); + + documentRepository.save(document); + + return DocumentInfoResDto.from(document); + } + + // 팀 문서 수정 + @Transactional + public DocumentInfoResDto update(Long documentId, DocumentUpdateReqDto documentUpdateReqDto) { + Document document = documentRepository.findById(documentId).orElseThrow(DocumentNotFoundException::new); + + document.update(documentUpdateReqDto.title()); + + return DocumentInfoResDto.from(document); + } + + // 팀 문서 조회 + public DocumentListResDto findDocumentByTeamDashboardId(Long teamDashboardId, Pageable pageable) { + Page documents = documentRepository.findByDocumentWithTeamDashboard(teamDashboardId, pageable); + + List documentInfoResDtoList = documents.stream() + .map(DocumentInfoResDto::from) + .toList(); + + return DocumentListResDto.of(documentInfoResDtoList, PageInfoResDto.from(documents)); + } + + // 팀 문서 삭제 + @Transactional + public void delete(Long documentId) { + Document document = documentRepository.findById(documentId).orElseThrow(DocumentNotFoundException::new); + + document.statusUpdate(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileService.java new file mode 100644 index 00000000..a3c1586c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileService.java @@ -0,0 +1,76 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.FileRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.FileNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FileService { + + private final DocumentRepository documentRepository; + private final FileRepository fileRepository; + + // 팀 파일 생성 + @Transactional + public FileInfoResDto save(String email, FileInfoReqDto fileInfoReqDto) { + Document document = documentRepository.findById(fileInfoReqDto.documentId()) + .orElseThrow(DocumentNotFoundException::new); + File file = fileInfoReqDto.toEntity(email, document); + + fileRepository.save(file); + + return FileInfoResDto.from(file); + } + + // 팀 파일 수정 + @Transactional + public FileInfoResDto update(Long fileID, FileInfoReqDto fileInfoReqDto) { + File file = fileRepository.findById(fileID).orElseThrow(FileNotFoundException::new); + + file.update(fileInfoReqDto.title(), fileInfoReqDto.content()); + + return FileInfoResDto.from(file); + } + + // 팀 파일 리스트 조회 + public FileListResDto findForFile(Long documentId, Pageable pageable) { + Page files = fileRepository.findByFilesWithDocumentId(documentId, pageable); + + List fileInfoResDtoList = files.stream() + .map(FileInfoResDto::from) + .toList(); + + return FileListResDto.of(fileInfoResDtoList, PageInfoResDto.from(files)); + } + + // 팀 파일 상세보기 + public FileInfoResDto findById(Long fileId) { + File file = fileRepository.findById(fileId).orElseThrow(FileNotFoundException::new); + + return FileInfoResDto.from(file); + } + + // 팀 파일 삭제 + @Transactional + public void delete(Long fileId) { + File file = fileRepository.findById(fileId).orElseThrow(FileNotFoundException::new); + + file.statusUpdate(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/Document.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/Document.java new file mode 100644 index 00000000..3fd7fdab --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/Document.java @@ -0,0 +1,52 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Document extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "teamDashboard_id") + private TeamDashboard teamDashboard; + + @OneToMany(mappedBy = "document", cascade = CascadeType.ALL) + private List files = new ArrayList<>(); + + @Builder + private Document(String title, TeamDashboard teamDashboard) { + this.status = Status.ACTIVE; + this.title = title; + this.teamDashboard = teamDashboard; + } + + public void update(String updateTitle) { + if (isUpdateRequired(updateTitle)) { + this.title = updateTitle; + } + } + + private boolean isUpdateRequired(String updateTitle) { + return !this.title.equals(updateTitle); + } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/File.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/File.java new file mode 100644 index 00000000..a6acba7f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/File.java @@ -0,0 +1,53 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class File extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String email; + + private String title; + + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + private Document document; + + @Builder + private File(String email, String title, String content, Document document) { + this.status = Status.ACTIVE; + this.email = email; + this.title = title; + this.content = content; + this.document = document; + } + + public void update(String updateTitle, String updateContent) { + if (isUpdateRequired(updateTitle, updateContent)) { + this.title = updateTitle; + this.content = updateContent; + } + } + + private boolean isUpdateRequired(String updateTitle, String updateContent) { + return !this.title.equals(updateTitle) || + !this.content.equals(updateContent); + } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepository.java new file mode 100644 index 00000000..e86a58a3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepository.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; + +public interface DocumentCustomRepository { + Page findByDocumentWithTeamDashboard(Long documentId, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepositoryImpl.java new file mode 100644 index 00000000..26ecad85 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepositoryImpl.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.QDocument.document; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Repository +@Transactional(readOnly = true) +public class DocumentCustomRepositoryImpl implements DocumentCustomRepository { + + private final JPAQueryFactory queryFactory; + + public DocumentCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findByDocumentWithTeamDashboard(Long teamDashboardId, Pageable pageable) { + long total = queryFactory + .selectFrom(document) + .where(document.teamDashboard.id.eq(teamDashboardId) + .and(document.status.eq(Status.ACTIVE))) + .fetchCount(); + + List documents = queryFactory + .selectFrom(document) + .where(document.teamDashboard.id.eq(teamDashboardId) + .and(document.status.eq(Status.ACTIVE))) + .orderBy(document.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(documents, pageable, total); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepository.java new file mode 100644 index 00000000..d8f3e226 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; + +public interface DocumentRepository extends JpaRepository, DocumentCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepository.java new file mode 100644 index 00000000..185ae2bb --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +public interface FileCustomRepository { + Page findByFilesWithDocumentId(Long documentId, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepositoryImpl.java new file mode 100644 index 00000000..a3067b6c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepositoryImpl.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.QFile.file; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Repository +@Transactional(readOnly = true) +public class FileCustomRepositoryImpl implements FileCustomRepository { + + private final JPAQueryFactory queryFactory; + + public FileCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findByFilesWithDocumentId(Long documentId, Pageable pageable) { + long total = queryFactory + .selectFrom(file) + .where(file.document.id.eq(documentId) + .and(file.status.eq(Status.ACTIVE))) + .fetchCount(); + + List files = queryFactory + .selectFrom(file) + .where(file.document.id.eq(documentId) + .and(file.status.eq(Status.ACTIVE))) + .orderBy(file.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(files, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepository.java new file mode 100644 index 00000000..aff17d41 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +public interface FileRepository extends JpaRepository, FileCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/DocumentNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/DocumentNotFoundException.java new file mode 100644 index 00000000..51cc9426 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/DocumentNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class DocumentNotFoundException extends NotFoundGroupException { + public DocumentNotFoundException(String message) { + super(message); + } + + public DocumentNotFoundException() { + this("존재하지 않는 팀 문서 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/FileNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/FileNotFoundException.java new file mode 100644 index 00000000..da2a1c69 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/FileNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class FileNotFoundException extends NotFoundGroupException { + public FileNotFoundException(String message) { + super(message); + } + + public FileNotFoundException() { + this("존재하지 않는 팀 파일 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/TeamDashboardNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/TeamDashboardNotFoundException.java new file mode 100644 index 00000000..84482462 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/TeamDashboardNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class TeamDashboardNotFoundException extends NotFoundGroupException { + public TeamDashboardNotFoundException(String message) { + super(message); + } + + public TeamDashboardNotFoundException() { + this("존재하지 않는 팀 대시보드 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotation/CurrentUserEmail.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotation/CurrentUserEmail.java new file mode 100644 index 00000000..af81fefe --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotation/CurrentUserEmail.java @@ -0,0 +1,12 @@ +package shop.kkeujeok.kkeujeokbackend.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUserEmail { +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolver.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolver.java new file mode 100644 index 00000000..59e7ef6c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolver.java @@ -0,0 +1,44 @@ +package shop.kkeujeok.kkeujeokbackend.global.annotationresolver; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +@Component +public class CurrentUserEmailArgumentResolver implements HandlerMethodArgumentResolver { + + private final TokenProvider tokenProvider; + + public CurrentUserEmailArgumentResolver(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(CurrentUserEmail.class) != null; + //@CurrentUserEmail 어노테이션으로 주석되어 있는지 확인하는 로직. + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String token = request.getHeader("Authorization"); + + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); // "Bearer " 이후의 토큰 부분만 추출하고 + TokenReqDto tokenReqDto = new TokenReqDto(token); + + return tokenProvider.getUserEmailFromToken(tokenReqDto); // 만들어둔 메서드로 email 값 반환 + } + + return null; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AnnotationWebConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AnnotationWebConfig.java new file mode 100644 index 00000000..b1e6e3b1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AnnotationWebConfig.java @@ -0,0 +1,22 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class AnnotationWebConfig implements WebMvcConfigurer { + + private final CurrentUserEmailArgumentResolver currentUserEmailArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserEmailArgumentResolver); + } +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AppConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AppConfig.java new file mode 100644 index 00000000..ef62877b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AppConfig.java @@ -0,0 +1,23 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Random; + +@Configuration +public class AppConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Bean + public Random random() { + return new Random(); + } +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/FilterWebConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/FilterWebConfig.java new file mode 100644 index 00000000..0f81c678 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/FilterWebConfig.java @@ -0,0 +1,34 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import shop.kkeujeok.kkeujeokbackend.global.filter.LogFilter; +import shop.kkeujeok.kkeujeokbackend.global.filter.LoginCheckFilter; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +@Configuration +@RequiredArgsConstructor +public class FilterWebConfig { + + private final TokenProvider tokenProvider; + + @Bean + public FilterRegistrationBean logFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(); + filterRegistrationBean.setFilter(new LogFilter()); // 여기서 만든 필터 클래스 등록 + filterRegistrationBean.setOrder(1); + filterRegistrationBean.addUrlPatterns("/*"); + return filterRegistrationBean; + } + + @Bean + public FilterRegistrationBean loginCheckFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(); + filterRegistrationBean.setFilter(new LoginCheckFilter(tokenProvider)); // JWT 토큰 유효성 검사를 위한 필터 클래스 등록 + filterRegistrationBean.setOrder(2); // 1번인 로그필터 다음으로 수행 + filterRegistrationBean.addUrlPatterns("/*"); + return filterRegistrationBean; + } +} \ No newline at end of file diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java index 09fb61f6..82e3d1ae 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java @@ -15,4 +15,5 @@ public class QuerydslConfig { public JPAQueryFactory jpaQueryFactory() { return new JPAQueryFactory(entityManager); } + } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/SchedulerConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/SchedulerConfig.java new file mode 100644 index 00000000..6d06310a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/dto/PageInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/dto/PageInfoResDto.java new file mode 100644 index 00000000..8fa35f2e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/dto/PageInfoResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.global.dto; + +import lombok.Builder; +import org.springframework.data.domain.Page; + +@Builder +public record PageInfoResDto( + int currentPage, + int totalPages, + long totalItems +) { + public static PageInfoResDto from(Page entityPage) { + return PageInfoResDto.builder() + .currentPage(entityPage.getNumber()) + .totalPages(entityPage.getTotalPages()) + .totalItems(entityPage.getTotalElements()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java index 3dd5beaf..728b75a1 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java @@ -1,6 +1,10 @@ package shop.kkeujeok.kkeujeokbackend.global.entity; public enum Status { - // A : 활성화, D : 비활성화 - A, D + ACTIVE("활성화"), + UN_ACTIVE("비활성화"), + DELETED("삭제"); + + Status(String description) { + } } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LogFilter.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LogFilter.java new file mode 100644 index 00000000..04f613bd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LogFilter.java @@ -0,0 +1,36 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter; + +import java.io.IOException; +import java.util.UUID; + + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.GenericFilterBean; + +@Slf4j +public class LogFilter extends GenericFilterBean { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + log.info("log filter doFilter"); + + HttpServletRequest httpRequest = (HttpServletRequest) request; + String requestURI = httpRequest.getRequestURI(); + + String uuid = UUID.randomUUID().toString(); + + try { + log.info("REQUEST [{}][{}]", uuid, requestURI); + chain.doFilter(request, response); + // chain이 없으면 여기서 끝난다. 즉, 로그만 띄우고 컨트롤러까지 가지 않아서 백지만 나온다. + // chain doFilter로 다시 호출해주면 controller로 넘어가서 정상적으로 페이지를 띄운다. + } catch (Exception e) { + throw e; + } finally { + log.info("REQUEST [{}][{}]", uuid, requestURI); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilter.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilter.java new file mode 100644 index 00000000..eda6e8fd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilter.java @@ -0,0 +1,69 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.PatternMatchUtils; +import org.springframework.web.filter.GenericFilterBean; +import shop.kkeujeok.kkeujeokbackend.global.filter.exceptiton.AuthenticationException; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class LoginCheckFilter extends GenericFilterBean { + + private static final String[] whiteList = { + "*", // 일단 다 열어둠 +// "/", +// "/api/oauth2/callback/**", +// "/api/*/token", +// "/api/token/access", + }; + + private final TokenProvider tokenProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + String requestURI = httpRequest.getRequestURI(); + try { + log.info("인증 체크 필터 시작{}", requestURI); + if (!isLoginCheckPath(requestURI)) { + log.info("인증 체크 로직 실행{}", requestURI); + String token = resolveToken(httpRequest); + if (token == null || !tokenProvider.validateToken(token)) { + log.info("미인증 사용자 요청 {}", requestURI); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + return; + } + // 토큰이 유효한 경우 사용자 정보를 로그로 출력 + } + chain.doFilter(request, response); + } catch (AuthenticationException e) { + throw e; + } finally { + log.info("인증 체크 필터 종료{}", requestURI); + } + } + + private boolean isLoginCheckPath(String requestURI) { + return PatternMatchUtils.simpleMatch(whiteList, requestURI); // 화이트리스트에 있는 경로는 true 반환 + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/exceptiton/AuthenticationException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/exceptiton/AuthenticationException.java new file mode 100644 index 00000000..dcadc2f4 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/exceptiton/AuthenticationException.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter.exceptiton; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AuthGroupException; + +public class AuthenticationException extends AuthGroupException { + + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException() { + this("인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하십시오."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java index 9f33f5e3..07843377 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java @@ -1,22 +1,24 @@ package shop.kkeujeok.kkeujeokbackend.global.jwt; import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; import java.security.Key; import java.util.Date; @Slf4j -@RequiredArgsConstructor +@Getter @Component +@NoArgsConstructor public class TokenProvider { @Value("${token.expire.time.access}") @@ -27,6 +29,7 @@ public class TokenProvider { @Value("${jwt.secret}") private String secret; + private Key key; public TokenProvider(String accessTokenExpireTime, String refreshTokenExpireTime, String secret, Key key) { @@ -38,8 +41,17 @@ public TokenProvider(String accessTokenExpireTime, String refreshTokenExpireTime @PostConstruct public void init() { - byte[] key = Decoders.BASE64URL.decode(secret); - this.key = Keys.hmacShaKeyFor(key); + byte[] keyBytes = hexStringToByteArray(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String getUserEmailFromToken(TokenReqDto tokenReqDto) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(tokenReqDto.authCode()) + .getBody(); + return claims.getSubject(); // 토큰의 subject를 사용자 ID로 간주 } public boolean validateToken(String token) { @@ -106,18 +118,13 @@ public String generateRefreshToken() { .compact(); } -// 시큐리티 규현 고민 중.. -// public Authentication getAuthentication(String token) { -// Claims claims = Jwts.parserBuilder() -// .setSigningKey(key) -// .build() -// .parseClaimsJws(token) -// .getBody(); -// -// Member member = memberRepository.findByEmail(claims.getSubject()).orElseThrow(); -// -// List authorities = List.of(new SimpleGrantedAuthority(member.getRole().toString())); -// -// return new UsernamePasswordAuthenticationToken(member, "", authorities); -// } + private byte[] hexStringToByteArray(String secret) { + int len = secret.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(secret.charAt(i), 16) << 4) + + Character.digit(secret.charAt(i + 1), 16)); + } + return data; + } } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java index 80c958dd..786c1dc9 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.IdTokenResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; import shop.kkeujeok.kkeujeokbackend.global.oauth.exception.OAuthException; @@ -21,31 +22,31 @@ @Transactional(readOnly = true) public class GoogleAuthService implements AuthService { - private final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String JWT_DELIMITER = "\\."; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; @Value("${google.client.id}") private String google_client_id; @Value("${google.client.secret}") private String google_client_secret; @Value("${google.redirect.uri}") - private String GOOGLE_REDIRECT_URI; - private static final String JWT_DELIMITER = "\\."; - - private final ObjectMapper objectMapper; + private String google_redirect_uri; - public GoogleAuthService(ObjectMapper objectMapper) { + public GoogleAuthService(ObjectMapper objectMapper, RestTemplate restTemplate) { this.objectMapper = objectMapper; + this.restTemplate = restTemplate; } - - public JsonNode getGoogleIdToken(String code) { - RestTemplate restTemplate = new RestTemplate(); + @Override + public IdTokenResDto getIdToken(String code) { Map params = Map.of( "code", code, "scope", "https://www.googleapis.com/auth/userinfo.profile " + "https://www.googleapis.com/auth/userinfo.email", "client_id", google_client_id, "client_secret", google_client_secret, - "redirect_uri", GOOGLE_REDIRECT_URI, + "redirect_uri", google_redirect_uri, "grant_type", "authorization_code" ); @@ -71,12 +72,14 @@ public UserInfo getUserInfo(String idToken) { } } - private JsonNode parseGoogleIdToken(ResponseEntity responseEntity) { + private IdTokenResDto parseGoogleIdToken(ResponseEntity responseEntity) { if (responseEntity.getStatusCode().is2xxSuccessful()) { String responseBody = responseEntity.getBody(); try { JsonNode jsonNode = objectMapper.readTree(responseBody); - return jsonNode.get("id_token"); + JsonNode idToken = jsonNode.get("id_token"); + + return new IdTokenResDto(idToken); } catch (Exception e) { throw new RuntimeException("ID 토큰을 파싱하는데 실패했습니다.", e); } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java index 1a0a1950..6d899ab8 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java @@ -14,6 +14,7 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.IdTokenResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; import shop.kkeujeok.kkeujeokbackend.global.oauth.exception.OAuthException; @@ -22,29 +23,27 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; +@Slf4j @Service @Transactional(readOnly = true) -@Slf4j public class KakaoAuthService implements AuthService { - private final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"; + private static final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"; + private static final String JWT_DELIMITER = "\\."; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; @Value("${oauth.kakao.rest-api-key}") private String restApiKey; @Value("${oauth.kakao.redirect-url}") private String redirectUri; - private static final String JWT_DELIMITER = "\\."; - - private final ObjectMapper objectMapper; - - public KakaoAuthService(ObjectMapper objectMapper) { + public KakaoAuthService(ObjectMapper objectMapper, RestTemplate restTemplate) { this.objectMapper = objectMapper; + this.restTemplate = restTemplate; } - - public JsonNode getKakaoAccessToken(String code) { - RestTemplate rt = new RestTemplate(); - + @Override + public IdTokenResDto getIdToken(String code) { HttpHeaders headers = new HttpHeaders(); headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); @@ -56,25 +55,25 @@ public JsonNode getKakaoAccessToken(String code) { HttpEntity> kakaoTokenRequest = new HttpEntity<>(params, headers); - ResponseEntity response = rt.exchange( - KAKAO_TOKEN_URL, - HttpMethod.POST, - kakaoTokenRequest, - String.class - ); - - - if (response.getStatusCode().is2xxSuccessful()) { - String responseBody = response.getBody(); - try { - JsonNode jsonNode = objectMapper.readTree(responseBody); - return jsonNode.get("id_token"); - } catch (Exception e) { - throw new RuntimeException("ID 토큰을 파싱하는데 실패했습니다.", e); - } + ResponseEntity response = restTemplate.exchange( + KAKAO_TOKEN_URL, + HttpMethod.POST, + kakaoTokenRequest, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + try { + JsonNode jsonNode = objectMapper.readTree(responseBody); + JsonNode idToken = jsonNode.get("id_token"); + + return new IdTokenResDto(idToken); + } catch (Exception e) { + throw new RuntimeException("ID 토큰을 파싱하는데 실패했습니다.", e); } - throw new RuntimeException("구글 엑세스 토큰을 가져오는데 실패했습니다."); - + } + throw new RuntimeException("구글 엑세스 토큰을 가져오는데 실패했습니다."); } @Override diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberController.java new file mode 100644 index 00000000..51ac9a04 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberController.java @@ -0,0 +1,43 @@ +package shop.kkeujeok.kkeujeokbackend.member.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.application.MyPageService; + +import java.util.Set; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/members") +public class MemberController { + + private final MyPageService myPageService; + + @GetMapping("/mypage") + public RspTemplate myProfileInfo(@CurrentUserEmail String email) { + MyPageInfoResDto memberResDto = myPageService.findMyProfileByEmail(email); + return new RspTemplate<>(HttpStatus.OK, "내 프로필 정보", memberResDto); + } + + @PatchMapping("/mypage") + public RspTemplate update(@CurrentUserEmail String email, + @RequestBody MyPageUpdateReqDto myPageUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, "내 프로필 정보 수정", myPageService.update(email, myPageUpdateReqDto)); + } + + @GetMapping("/mypage/dashboard-challenges") + public RspTemplate getTeamDashboardsAndChallenges(@CurrentUserEmail String email, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + TeamDashboardsAndChallengesResDto response = myPageService.findTeamDashboardsAndChallenges(email, PageRequest.of(page, size)); + return new RspTemplate<>(HttpStatus.OK, "팀 대시보드와 챌린지 정보 조회", response); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java index 904172c2..abbb70c5 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java @@ -1,12 +1,20 @@ package shop.kkeujeok.kkeujeokbackend.member.domain; -import jakarta.persistence.*; -import lombok.AccessLevel; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; @Entity @Getter @@ -32,8 +40,25 @@ public class Member extends BaseEntity { private String nickname; + private String introduction; + + @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private List challenges = new ArrayList<>(); + + private String tag; + + @OneToMany(mappedBy = "receiver", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private List notifications = new ArrayList<>(); + @Builder - private Member(Status status, Role role, String email, String name, String picture, SocialType socialType, boolean firstLogin, String nickname) { + private Member(Status status, Role role, + String email, String name, + String picture, + SocialType socialType, + boolean firstLogin, + String nickname, + String introduction, + String tag) { this.status = status; this.role = role; this.email = email; @@ -42,5 +67,19 @@ private Member(Status status, Role role, String email, String name, String pictu this.socialType = socialType; this.firstLogin = firstLogin; this.nickname = nickname; + this.introduction = introduction; + this.tag = tag; + } + + public void update(String nickname, String introduction) { + if (isUpdateRequired(nickname, introduction)) { + this.nickname = nickname; + this.introduction = introduction; + } + } + + private boolean isUpdateRequired(String updateNickname, String updateIntroduction) { + return !this.nickname.equals(updateNickname) || + !this.introduction.equals(updateIntroduction); } } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepository.java new file mode 100644 index 00000000..40b85f74 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepository.java @@ -0,0 +1,5 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain.repository; + +public interface MemberCustomRepository { + boolean existsByNicknameAndTag(String nickname, String tag); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepositoryImpl.java new file mode 100644 index 00000000..f48244e8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepositoryImpl.java @@ -0,0 +1,21 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.member.domain.QMember; + +@RequiredArgsConstructor +public class MemberCustomRepositoryImpl implements MemberCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public boolean existsByNicknameAndTag(String nickname, String tag) { + QMember member = QMember.member; + + return queryFactory.selectFrom(member) + .where(member.nickname.eq(nickname) + .and(member.tag.eq(tag))) + .fetchFirst() != null; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java index be690077..4ed8b605 100644 --- a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java @@ -6,7 +6,10 @@ import java.util.Optional; -public interface MemberRepository extends JpaRepository, JpaSpecificationExecutor { - +public interface MemberRepository extends + JpaRepository, + JpaSpecificationExecutor, + MemberCustomRepository { Optional findByEmail(String email); + boolean existsByNickname(String nickname); } diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/exception/MemberNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/exception/MemberNotFoundException.java new file mode 100644 index 00000000..6afcfa20 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/exception/MemberNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.member.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class MemberNotFoundException extends NotFoundGroupException { + public MemberNotFoundException(String message) { + super(message); + } + + public MemberNotFoundException() { + this("존재하지 않는 회원입니다"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/request/MyPageUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/request/MyPageUpdateReqDto.java new file mode 100644 index 00000000..87d28d24 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/request/MyPageUpdateReqDto.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request; + +public record MyPageUpdateReqDto( + String nickname, + String introduction +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/MyPageInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/MyPageInfoResDto.java new file mode 100644 index 00000000..66923bde --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/MyPageInfoResDto.java @@ -0,0 +1,27 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@Builder +public record MyPageInfoResDto( + String picture, + String email, + String name, + String nickName, + SocialType socialType, + String introduction + +) { + public static MyPageInfoResDto From(Member member) { + return MyPageInfoResDto.builder() + .picture(member.getPicture()) + .email(member.getEmail()) + .name(member.getName()) + .nickName(member.getNickname()) + .socialType(member.getSocialType()) + .introduction(member.getIntroduction()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/TeamDashboardsAndChallengesResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/TeamDashboardsAndChallengesResDto.java new file mode 100644 index 00000000..093700d0 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/TeamDashboardsAndChallengesResDto.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response; + +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; + +public record TeamDashboardsAndChallengesResDto( + TeamDashboardListResDto teamDashboardList, + ChallengeListResDto challengeList +) { + public static TeamDashboardsAndChallengesResDto of(TeamDashboardListResDto teamDashboardList, ChallengeListResDto challengeList) { + return new TeamDashboardsAndChallengesResDto( + teamDashboardList, + challengeList + ); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageService.java new file mode 100644 index 00000000..3e670846 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageService.java @@ -0,0 +1,69 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.auth.exception.EmailNotFoundException; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.exception.ExistsNicknameException; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageService { + + private final MemberRepository memberRepository; + private final TeamDashboardService teamDashboardService; + private final ChallengeService challengeService; + + // 프로필 정보 조회 + public MyPageInfoResDto findMyProfileByEmail(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(); + + return MyPageInfoResDto.From(member); + } + + // 프로필 정보 수정 + @Transactional + public MyPageInfoResDto update(String email, MyPageUpdateReqDto myPageUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(EmailNotFoundException::new); + + if (isNicknameChanged(member, myPageUpdateReqDto.nickname()) && isNicknameDuplicate(myPageUpdateReqDto.nickname())) { + throw new ExistsNicknameException(); + } + + member.update(myPageUpdateReqDto.nickname(), myPageUpdateReqDto.introduction()); + + return MyPageInfoResDto.From(member); + } + + // 팀 대시보드 & 챌린지 정보 조회 + @Transactional(readOnly = true) + public TeamDashboardsAndChallengesResDto findTeamDashboardsAndChallenges(String email, Pageable pageable) { + TeamDashboardListResDto teamDashboardListResDto = teamDashboardService.findForTeamDashboard(email, pageable); + ChallengeListResDto challengeListResDto = challengeService.findChallengeForMemberId(email, pageable); + + return TeamDashboardsAndChallengesResDto.of(teamDashboardListResDto, challengeListResDto); + } + + private boolean isNicknameChanged(Member member, String newNickname) { + return !normalizeNickname(member.getNickname()).equals(normalizeNickname(newNickname)); + } + + private boolean isNicknameDuplicate(String nickname) { + return memberRepository.existsByNickname(normalizeNickname(nickname)); + } + + private String normalizeNickname(String nickname) { + return nickname.replaceAll("\\s+", ""); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/exception/ExistsNicknameException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/exception/ExistsNicknameException.java new file mode 100644 index 00000000..7d40d16b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/exception/ExistsNicknameException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class ExistsNicknameException extends InvalidGroupException { + public ExistsNicknameException(String message) { + super(message); + } + + public ExistsNicknameException() { + this("이미 사용중인 닉네임 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameService.java new file mode 100644 index 00000000..2d06a00e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameService.java @@ -0,0 +1,41 @@ +package shop.kkeujeok.kkeujeokbackend.member.nickname.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Random; + +@Service +@Transactional(readOnly = true) +public class NicknameService { + + private final List adjectives; + private final List nouns; + private final Random random; + + public NicknameService(@Qualifier("adjectives") List adjectives, + @Qualifier("nouns") List nouns, + Random random) { + this.adjectives = adjectives; + this.nouns = nouns; + this.random = random; + } + + public String getRandomNickname() { + return generateNickname(); + } + + private String generateNickname() { + String adjective = getRandomElement(adjectives); + String noun = getRandomElement(nouns); + + return adjective + noun; + } + + private String getRandomElement(List list) { + return list.get(random.nextInt(list.size())); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/config/NicknameConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/config/NicknameConfig.java new file mode 100644 index 00000000..ad937d2b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/config/NicknameConfig.java @@ -0,0 +1,31 @@ +package shop.kkeujeok.kkeujeokbackend.member.nickname.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Qualifier; + +import java.util.List; + +@Configuration +public class NicknameConfig { + + @Bean + @Qualifier("adjectives") + public List adjectives() { + return List.of( + "귀여운", "행복한", "깜찍한", "명랑한", "재미있는", + "용감한", "사려깊은", "활기찬", "사랑스러운", "친절한", + "밝은", "기분좋은", "즐거운", "신나는", "멋진" + ); + } + + @Bean + @Qualifier("nouns") + public List nouns() { + return List.of( + "고양이", "강아지", "토끼", "곰", "여우", + "판다", "호랑이", "사자", "다람쥐", "고슴도치", + "햄스터", "펭귄", "수달", "부엉이", "돌고래" + ); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/tag/application/TagService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/tag/application/TagService.java new file mode 100644 index 00000000..8bde1513 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/tag/application/TagService.java @@ -0,0 +1,37 @@ +package shop.kkeujeok.kkeujeokbackend.member.tag.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +import java.util.Random; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TagService { + + private final Random random; + private final MemberRepository memberRepository; + + public String getRandomTag(String nickname) { + return generateUniqueTag(nickname); + } + + private String generateUniqueTag(String nickname) { + String tag = generateTag(); + + if (memberRepository.existsByNicknameAndTag(nickname, tag)) { + return generateUniqueTag(nickname); + } + + return tag; + } + + private String generateTag() { + int randomNumber = 1000 + random.nextInt(9000); + + return "#" + randomNumber; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationController.java new file mode 100644 index 00000000..5ca1fa70 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationController.java @@ -0,0 +1,44 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationInfoResDto; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/stream") + public SseEmitter streamNotifications(@CurrentUserEmail String email) { + return notificationService.createEmitter(email); + } + + @GetMapping + public RspTemplate findAllNotifications(@CurrentUserEmail String email, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, "알림 조회 성공", + notificationService.findAllNotificationsFromMember(email, PageRequest.of(page, size))); + } + + @GetMapping("/{notificationId}") + public RspTemplate findNotificationById( + @PathVariable(name = "notificationId") Long notificationId) { + return new RspTemplate<>(HttpStatus.OK, "알림 상세 조회 성공", + notificationService.findByNotificationId(notificationId)); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationInfoResDto.java new file mode 100644 index 00000000..849013d2 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationInfoResDto.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +@Builder +public record NotificationInfoResDto( + Long id, + String message, + Boolean isRead +) { + public static NotificationInfoResDto from(Notification notification) { + return NotificationInfoResDto.builder() + .id(notification.getId()) + .message(notification.getMessage()) + .isRead(notification.getIsRead()) + .build(); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationListResDto.java new file mode 100644 index 00000000..f145a5f9 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationListResDto.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record NotificationListResDto( + List notificationInfoResDto, + PageInfoResDto pageInfoResDto + +) { + public static NotificationListResDto of(List notificationInfoResDtoList, + PageInfoResDto pageInfoResDto) { + return NotificationListResDto.builder() + .notificationInfoResDto(notificationInfoResDtoList) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationService.java new file mode 100644 index 00000000..0d98cb99 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationService.java @@ -0,0 +1,73 @@ +package shop.kkeujeok.kkeujeokbackend.notification.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationInfoResDto; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; +import shop.kkeujeok.kkeujeokbackend.notification.domain.repository.NotificationRepository; +import shop.kkeujeok.kkeujeokbackend.notification.exception.NotificationNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.util.SseEmitterManager; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final MemberRepository memberRepository; + private final SseEmitterManager sseEmitterManager; + private final NotificationRepository notificationRepository; + + public SseEmitter createEmitter(String email) { + Member member = findMemberByEmail(email); + + return sseEmitterManager.createEmitter(member.getId()); + } + + @Transactional + public void sendNotification(Member member, String message) { + Notification notification = Notification.builder() + .receiver(member) + .message(message) + .isRead(false) + .build(); + + Notification savedNotification = notificationRepository.save(notification); + + sseEmitterManager.sendNotification(member.getId(), savedNotification.getMessage()); + } + + @Transactional(readOnly = true) + public NotificationListResDto findAllNotificationsFromMember(String email, Pageable pageable) { + Member member = findMemberByEmail(email); + Page notifications = notificationRepository.findAllNotifications(member, pageable); + + List notificationList = notifications.stream() + .map(NotificationInfoResDto::from) + .toList(); + + return NotificationListResDto.of(notificationList, PageInfoResDto.from(notifications)); + } + + @Transactional + public NotificationInfoResDto findByNotificationId(Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(NotificationNotFoundException::new); + notification.markAsRead(); + + return NotificationInfoResDto.from(notification); + } + + private Member findMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/Notification.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/Notification.java new file mode 100644 index 00000000..1ca94583 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/Notification.java @@ -0,0 +1,41 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "member_id") + private Member receiver; + + private String message; + + @Column(nullable = false) + private Boolean isRead; + + @Builder + public Notification(Member receiver, String message, Boolean isRead) { + this.receiver = receiver; + this.message = message; + this.isRead = isRead; + } + + public void markAsRead() { + if (isRead) { + return; + } + isRead = true; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepository.java new file mode 100644 index 00000000..3c053fc7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +public interface NotificationCustomRepository { + Page findAllNotifications(Member member, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepositoryImpl.java new file mode 100644 index 00000000..39521295 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepositoryImpl.java @@ -0,0 +1,45 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; +import shop.kkeujeok.kkeujeokbackend.notification.domain.QNotification; + +@Repository +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllNotifications(Member member, Pageable pageable) { + QNotification notification = QNotification.notification; + + long total = Optional.ofNullable( + queryFactory + .select(notification.count()) + .from(notification) + .where(notification.receiver.eq(member)) + .fetchOne() + ).orElse(0L); + + List notifications = queryFactory + .selectFrom(notification) + .where(notification.receiver.eq(member)) + .orderBy(notification.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(notifications, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationRepository.java new file mode 100644 index 00000000..b2ab3b69 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +public interface NotificationRepository extends JpaRepository, NotificationCustomRepository { + List findAllByReceiver(Member member); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/exception/NotificationNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/exception/NotificationNotFoundException.java new file mode 100644 index 00000000..717f79c0 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/exception/NotificationNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.notification.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class NotificationNotFoundException extends NotFoundGroupException { + public NotificationNotFoundException(String message) { + super(message); + } + + public NotificationNotFoundException() { + this("존재하지 않는 알림입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/util/SseEmitterManager.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/util/SseEmitterManager.java new file mode 100644 index 00000000..348a24c9 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/util/SseEmitterManager.java @@ -0,0 +1,37 @@ +package shop.kkeujeok.kkeujeokbackend.notification.util; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@Component +public class SseEmitterManager { + + private final Map emitters = new ConcurrentHashMap<>(); + + public SseEmitter createEmitter(Long memberId) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + emitters.put(memberId, emitter); + + emitter.onCompletion(() -> emitters.remove(memberId)); + emitter.onTimeout(() -> emitters.remove(memberId)); + emitter.onError((e) -> emitters.remove(memberId)); + + return emitter; + } + + public void sendNotification(Long memberId, String message) { + SseEmitter emitter = emitters.get(memberId); + + if (emitter != null) { + try { + emitter.send(SseEmitter.event().name("notification").data(message)); + } catch (Exception e) { + emitter.completeWithError(e); + } + } + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/KkeujeokBackendApplicationTests.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/KkeujeokBackendApplicationTests.java deleted file mode 100644 index 7df3719f..00000000 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/KkeujeokBackendApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package shop.kkeujeok.kkeujeokbackend; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class KkeujeokBackendApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java index f727070c..fd6551ab 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java @@ -1,4 +1,124 @@ package shop.kkeujeok.kkeujeokbackend.auth.api; -public class AuthControllerTest { +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +public class AuthControllerTest extends ControllerTest { + + private Member member; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + member = Member.builder() + .email("email") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .build(); + } + + @DisplayName("로그인하면 accessToken과 refreshToken을 반환합니다.") + @Test + public void 로그인하면_accessToken과_refreshToken을_반환합니다() throws Exception { + String provider = "google"; + TokenReqDto tokenReqDto = new TokenReqDto("auth-code"); + UserInfo userInfo = new UserInfo("email", "name", "picture", "nickname"); + MemberLoginResDto memberLoginResDto = MemberLoginResDto.from(member); + TokenDto tokenDto = new TokenDto("new-access-token", "new-refresh-token"); + + given(authServiceFactory.getAuthService(provider)).willReturn(authService); + given(authService.getUserInfo(any(String.class))).willReturn(userInfo); + given(authMemberService.saveUserInfo(any(UserInfo.class), any(SocialType.class))).willReturn(memberLoginResDto); + given(tokenService.getToken(any(MemberLoginResDto.class))).willReturn(tokenDto); + + mockMvc.perform(post("/api/google/token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(tokenReqDto))) + .andDo(print()) + .andDo(document("auth/login", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("authCode").description("ID 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("응답 상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.accessToken").description("엑세스 토큰"), + fieldWithPath("data.refreshToken").description("리프레시 토큰") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.message").value("토큰 발급")) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")); + + } + + @DisplayName("리프레쉬 토큰으로 액세스 토큰을 발급합니다.") + @Test + public void 리프레쉬_토큰으로_액세스_토큰을_발급합니다() throws Exception { + RefreshTokenReqDto refreshTokenReqDto = new RefreshTokenReqDto("test-refresh-token"); + TokenDto tokenDto = new TokenDto("new-access-token", "test-refresh-token"); + + given(tokenService.generateAccessToken(any(RefreshTokenReqDto.class))).willReturn(tokenDto); + + mockMvc.perform(post("/api/token/access") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(refreshTokenReqDto))) + .andDo(print()) + .andDo(document("auth/getNewAccessToken", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("refreshToken").description("리프레시 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("응답 상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.accessToken").description("새로운 엑세스 토큰"), + fieldWithPath("data.refreshToken").description("새로운 리프레시 토큰") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(200)) + .andExpect(jsonPath("$.message").value("액세스 토큰 발급")) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("test-refresh-token")); + } } diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java index e454378f..642ff455 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java @@ -7,6 +7,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; import shop.kkeujeok.kkeujeokbackend.global.entity.Status; @@ -14,6 +15,8 @@ import shop.kkeujeok.kkeujeokbackend.member.domain.Role; import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.nickname.application.NicknameService; +import shop.kkeujeok.kkeujeokbackend.member.tag.application.TagService; import java.util.Optional; @@ -23,12 +26,19 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) class AuthMemberServiceTest { @Mock private MemberRepository memberRepository; + @Mock + private NicknameService nicknameService; + + @Mock + private TagService tagService; + @InjectMocks private AuthMemberService authMemberService; @@ -41,7 +51,7 @@ void setUp() { userInfo = new UserInfo("이메일", "이름", "사진", "닉네임"); provider = SocialType.GOOGLE; member = Member.builder() - .status(Status.A) + .status(Status.ACTIVE) .email(userInfo.email()) .name(userInfo.name()) .picture(userInfo.picture()) @@ -49,6 +59,7 @@ void setUp() { .role(Role.ROLE_USER) .firstLogin(true) .nickname(userInfo.nickname()) + .tag("#0000") .build(); } @@ -75,7 +86,7 @@ void setUp() { assertThat(result).isNotNull(); assertThat(result.findMember().getEmail()).isEqualTo(userInfo.email()); - assertThat(result.findMember().getName()).isEqualTo(userInfo.name()); + assertThat(result.findMember().getName()).isEqualTo(userInfo.nickname()); verify(memberRepository).findByEmail(userInfo.email()); verify(memberRepository).save(any(Member.class)); } diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java index bebffe70..d008e588 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java @@ -6,13 +6,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; import java.util.Arrays; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; - +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) class AuthServiceFactoryTest { diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java index 85e1986d..e5c64461 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java @@ -7,6 +7,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; @@ -24,6 +25,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) class TokenServiceTest { diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java index fb091f2b..8f2e2b08 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java @@ -4,122 +4,254 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.mockito.InjectMocks; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; -import shop.kkeujeok.kkeujeokbackend.block.application.BlockService; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; import shop.kkeujeok.kkeujeokbackend.block.domain.Block; import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; import shop.kkeujeok.kkeujeokbackend.block.exception.InvalidProgressException; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; -@WebMvcTest(BlockController.class) -@MockBean(JpaMetamodelMappingContext.class) -class BlockControllerTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private BlockService blockService; +class BlockControllerTest extends ControllerTest { private Member member; private Block block; private BlockSaveReqDto blockSaveReqDto; private BlockUpdateReqDto blockUpdateReqDto; + private BlockSequenceUpdateReqDto blockSequenceUpdateReqDto; + + @InjectMocks + BlockController blockController; @BeforeEach - void setUp() { + void setUp(RestDocumentationContextProvider restDocumentation) { member = Member.builder() - .nickname("웅이") + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + Dashboard dashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .dType("PersonalDashboard") + .isPublic(false) + .category("category") + .build(); + + blockSaveReqDto = new BlockSaveReqDto(1L, "Title", "Contents", Progress.NOT_STARTED, "2024.07.03 13:23", + "2024.08.03 13:23"); + blockUpdateReqDto = new BlockUpdateReqDto("UpdateTitle", "UpdateContents", "2024.07.03 13:23", + "2024.07.28 16:40"); + block = blockSaveReqDto.toEntity(member, dashboard, 0); + + ReflectionTestUtils.setField(block, "id", 1L); + + blockSequenceUpdateReqDto = new BlockSequenceUpdateReqDto( + List.of(2L, 3L), + List.of(3L, 1L), + List.of(1L, 2L) + ); + + blockController = new BlockController(blockService); + + mockMvc = MockMvcBuilders.standaloneSetup(blockController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) .build(); - blockSaveReqDto = new BlockSaveReqDto("Title", "Contents", Progress.NOT_STARTED); - blockUpdateReqDto = new BlockUpdateReqDto("UpdateTitle", "UpdateContents"); - block = blockSaveReqDto.toEntity(member); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); } @DisplayName("POST 블록 저장 컨트롤러 로직 확인") @Test void 블록_저장() throws Exception { // given - BlockInfoResDto response = BlockInfoResDto.of(block, member); - given(blockService.save(any(BlockSaveReqDto.class))).willReturn(response); + BlockInfoResDto response = BlockInfoResDto.from(block); + given(blockService.save(anyString(), any(BlockSaveReqDto.class))).willReturn(response); // when & then mockMvc.perform(post("/api/blocks/") + .header("Authorization", "Bearer valid-token") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(blockSaveReqDto)) - ).andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.statusCode").value(201)) - .andExpect(jsonPath("$.message").value("블럭 생성")) - .andExpect(jsonPath("$.data.title").value("Title")) - .andExpect(jsonPath("$.data.contents").value("Contents")) - .andExpect(jsonPath("$.data.progress").value("NOT_STARTED")) - .andExpect(jsonPath("$.data.nickname").value("웅이")); + .content(objectMapper.writeValueAsString(blockSaveReqDto))) + .andDo(print()) + .andDo(document("block/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("dashboardId").description("대시보드 아이디"), + fieldWithPath("title").description("블록 제목"), + fieldWithPath("contents").description("블록 내용"), + fieldWithPath("progress").description("블록 진행 상태"), + fieldWithPath("startDate").description("블록 시작 시간"), + fieldWithPath("deadLine").description("블록 마감 시간") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.picture").description("회원 사진"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); } - @DisplayName("Patch 블록 수정 컨트롤러 로직 확인") + @DisplayName("PATCH 블록 수정 컨트롤러 로직 확인") @Test void 블록_수정() throws Exception { // given - block.update(blockUpdateReqDto.title(), blockUpdateReqDto.contents()); - BlockInfoResDto response = BlockInfoResDto.of(block, member); - given(blockService.update(anyLong(), any(BlockUpdateReqDto.class))).willReturn(response); + block.update(blockUpdateReqDto.title(), blockUpdateReqDto.contents(), blockUpdateReqDto.startDate(), + blockUpdateReqDto.deadLine()); + BlockInfoResDto response = BlockInfoResDto.from(block); + given(blockService.update(anyString(), anyLong(), any(BlockUpdateReqDto.class))).willReturn(response); // when & then mockMvc.perform(patch("/api/blocks/{blockId}", 1L) + .header("Authorization", "Bearer valid-token") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(blockUpdateReqDto)) - ).andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.statusCode").value(200)) - .andExpect(jsonPath("$.message").value("블록 수정")) - .andExpect(jsonPath("$.data.title").value("UpdateTitle")) - .andExpect(jsonPath("$.data.contents").value("UpdateContents")) - .andExpect(jsonPath("$.data.progress").value("NOT_STARTED")) - .andExpect(jsonPath("$.data.nickname").value("웅이")); + .content(objectMapper.writeValueAsString(blockUpdateReqDto))) + .andDo(print()) + .andDo(document("block/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + requestFields( + fieldWithPath("title").description("블록 제목"), + fieldWithPath("contents").description("블록 내용"), + fieldWithPath("startDate").description("블록 시작 시간"), + fieldWithPath("deadLine").description("블록 마감 시간") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.picture").description("회원 사진"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); } - @DisplayName("Patch 블록 상태 수정 컨트롤러 로직 확인") + @DisplayName("PATCH 블록 상태 수정 컨트롤러 로직 확인") @Test void 블록_상태_수정() throws Exception { // given Long blockId = 1L; String progressString = "IN_PROGRESS"; - BlockInfoResDto response = BlockInfoResDto.of(block, member); + BlockInfoResDto response = BlockInfoResDto.from(block); - given(blockService.progressUpdate(anyLong(), anyString())).willReturn(response); + given(blockService.progressUpdate(anyString(), anyLong(), anyString())).willReturn(response); // when & then - mockMvc.perform(patch("/api/blocks/{blockId}/progress", blockId) - .param("progress", progressString) - .accept(MediaType.APPLICATION_JSON) - ).andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.statusCode").value(200)) - .andExpect(jsonPath("$.message").value("블록 상태 수정")) - .andExpect(jsonPath("$.data").exists()); + mockMvc.perform(patch(String.format("/api/blocks/{blockId}/progress?progress=%s", progressString), blockId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/progress/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + queryParameters( + parameterWithName("progress") + .description("블록 상태 문자열(NOT_STARTED, IN_PROGRESS, COMPLETED)") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.picture").description("회원 사진"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); } @DisplayName("Patch 블록 상태 수정 실패 컨트롤러 로직 확인(400 Bad Request가 발생한다)") @@ -129,13 +261,238 @@ void setUp() { Long blockId = 1L; String progressString = "STATUS_PROGRESS"; - given(blockService.progressUpdate(anyLong(), anyString())).willThrow(new InvalidProgressException()); + given(blockService.progressUpdate(anyString(), anyLong(), anyString())).willThrow( + new InvalidProgressException()); // when & then - mockMvc.perform(patch("/api/blocks/{blockId}/progress", blockId) - .param("progress", progressString) - .accept(MediaType.APPLICATION_JSON) - ).andDo(print()) + mockMvc.perform(patch(String.format("/api/blocks/{blockId}/progress?progress=%s", progressString), blockId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/progress/update/failure", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + queryParameters( + parameterWithName("progress") + .description("블록 상태 문자열(NOT_STARTED, IN_PROGRESS, COMPLETED)") + ) + )) .andExpect(status().isBadRequest()); } + + @DisplayName("Delete 블록을 논리적으로 삭제하고, 복구합니다.") + @Test + void 블록_삭제() throws Exception { + // given + Long blockId = 1L; + doNothing().when(blockService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/blocks/{blockId}", blockId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 블록을 상태별로 전체 조회합니다.") + @Test + void 블록_상태_전체_조회() throws Exception { + // given + String progressString = "NOT_STARTED"; + Page blockPage = new PageImpl<>(List.of(block), PageRequest.of(0, 10), 1); + BlockListResDto response = BlockListResDto.from( + Collections.singletonList(BlockInfoResDto.from(block)), + PageInfoResDto.from(blockPage)); + + given(blockService.findForBlockByProgress(anyString(), anyLong(), anyString(), any())).willReturn(response); + + // when & then + mockMvc.perform( + get(String.format("/api/blocks?dashboardId=%d&progress=%s&page=%d&size=%d", 1L, progressString, 0, 10)) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/findByBlockWithProgress", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("dashboardId") + .description("대시보드 아이디"), + parameterWithName("progress") + .description("블록 상태 문자열(NOT_STARTED, IN_PROGRESS, COMPLETED)"), + parameterWithName("page") + .description("페이지 번호"), + parameterWithName("size") + .description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockListResDto[].blockId").description("블록 아이디"), + fieldWithPath("data.blockListResDto[].title").description("블록 제목"), + fieldWithPath("data.blockListResDto[].contents").description("블록 내용"), + fieldWithPath("data.blockListResDto[].progress").description("블록 진행 상태"), + fieldWithPath("data.blockListResDto[].type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.blockListResDto[].dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.blockListResDto[].startDate").description("블록 시작 시간"), + fieldWithPath("data.blockListResDto[].deadLine").description("블록 마감 시간"), + fieldWithPath("data.blockListResDto[].nickname").description("회원 닉네임"), + fieldWithPath("data.blockListResDto[].picture").description("회원 사진"), + fieldWithPath("data.blockListResDto[].dDay").description("마감 기한"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 블록을 상세봅니다.") + @Test + void 블록_상세보기() throws Exception { + // given + BlockInfoResDto response = BlockInfoResDto.from(block); + + given(blockService.findById(anyString(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/blocks/{blockId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.picture").description("회원 사진"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 블록의 순번을 변경합니다.") + @Test + void 블록_순번_변경() throws Exception { + // given + doNothing().when(blockService).changeBlocksSequence(anyString(), any()); + + // when & then + mockMvc.perform(patch("/api/blocks/change") + .header("Authorization", "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blockSequenceUpdateReqDto))) + .andDo(print()) + .andDo(document("block/change", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("notStartedList").description("시작 전 블록 아이디 리스트"), + fieldWithPath("inProgressList").description("진행 중 블록 아이디 리스트"), + fieldWithPath("completedList").description("완료 블록 아이디 리스트") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 삭제된 블록을 조회합니다.") + @Test + void 삭제_블록_조회() throws Exception { + // given + block.statusUpdate(); + Page blockPage = new PageImpl<>(List.of(block), PageRequest.of(0, 10), 1); + BlockListResDto response = BlockListResDto.from( + Collections.singletonList(BlockInfoResDto.from(block)), + PageInfoResDto.from(blockPage)); + + given(blockService.findDeletedBlocks(anyString(), anyLong(), any())).willReturn(response); + + // when & then + mockMvc.perform( + get(String.format("/api/blocks/deleted?dashboardId=%d&page=%d&size=%d", 1L, 0, 10)) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/findDeletedBlocks", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("dashboardId") + .description("대시보드 아이디"), + parameterWithName("page") + .description("페이지 번호"), + parameterWithName("size") + .description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockListResDto[].blockId").description("블록 아이디"), + fieldWithPath("data.blockListResDto[].title").description("블록 제목"), + fieldWithPath("data.blockListResDto[].contents").description("블록 내용"), + fieldWithPath("data.blockListResDto[].progress").description("블록 진행 상태"), + fieldWithPath("data.blockListResDto[].type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.blockListResDto[].dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.blockListResDto[].startDate").description("블록 시작 시간"), + fieldWithPath("data.blockListResDto[].deadLine").description("블록 마감 시간"), + fieldWithPath("data.blockListResDto[].nickname").description("회원 닉네임"), + fieldWithPath("data.blockListResDto[].picture").description("회원 사진"), + fieldWithPath("data.blockListResDto[].dDay").description("마감 기한"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETED 블록을 영구 삭제 합니다.") + @Test + void 블록_영구_삭제() throws Exception { + // given + doNothing().when(blockService).deletePermanently(anyString(), any()); + + // when & then + mockMvc.perform(delete("/api/blocks/permanent/{blockId}", 1L) + .header("Authorization", "Bearer token") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/deletePermanentBlock", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ) + )) + .andExpect(status().isOk()); + } + } \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java index 7f930619..c7f9a589 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java @@ -5,8 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,12 +21,20 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; import shop.kkeujeok.kkeujeokbackend.block.domain.Block; import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; import shop.kkeujeok.kkeujeokbackend.block.exception.InvalidProgressException; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; @ExtendWith(MockitoExtension.class) class BlockServiceTest { @@ -29,22 +42,75 @@ class BlockServiceTest { @Mock private BlockRepository blockRepository; + @Mock + private MemberRepository memberRepository; + + @Mock + private DashboardRepository dashboardRepository; + @InjectMocks private BlockService blockService; + private Member member; private Block block; + private Block deleteBlock; + private Dashboard dashboard; private BlockSaveReqDto blockSaveReqDto; private BlockUpdateReqDto blockUpdateReqDto; + private BlockSequenceUpdateReqDto blockSequenceUpdateReqDto; @BeforeEach void setUp() { - blockSaveReqDto = new BlockSaveReqDto("Title", "Contents", Progress.NOT_STARTED); - blockUpdateReqDto = new BlockUpdateReqDto("UpdateTitle", "UpdateContents"); + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.ofNullable(member)); + + dashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + + blockSaveReqDto = new BlockSaveReqDto(1L, "Title", "Contents", Progress.NOT_STARTED, "2024.07.03 13:23", + "2024.07.25 13:23"); + blockUpdateReqDto = new BlockUpdateReqDto("UpdateTitle", "UpdateContents", "2024.07.03 13:23", + "2024.07.28 16:40"); + + blockSequenceUpdateReqDto = new BlockSequenceUpdateReqDto( + List.of(1L, 2L), + List.of(3L, 4L), + List.of(5L, 6L) + ); + block = Block.builder() .title(blockSaveReqDto.title()) .contents(blockSaveReqDto.contents()) .progress(blockSaveReqDto.progress()) + .startDate(blockSaveReqDto.startDate()) + .deadLine(blockSaveReqDto.deadLine()) + .member(member) + .dashboard(dashboard) .build(); + + deleteBlock = Block.builder() + .title(blockSaveReqDto.title()) + .contents(blockSaveReqDto.contents()) + .progress(blockSaveReqDto.progress()) + .startDate(blockSaveReqDto.startDate()) + .deadLine(blockSaveReqDto.deadLine()) + .member(member) + .dashboard(dashboard) + .build(); + deleteBlock.statusUpdate(); } @DisplayName("블록을 저장합니다.") @@ -52,18 +118,19 @@ void setUp() { void 블록_저장() { // given when(blockRepository.save(any(Block.class))).thenReturn(block); + when(dashboardRepository.findById(anyLong())).thenReturn(Optional.of(dashboard)); // when - BlockInfoResDto result = blockService.save(blockSaveReqDto); + BlockInfoResDto result = blockService.save("email", blockSaveReqDto); // then assertAll(() -> { assertThat(result.title()).isEqualTo("Title"); assertThat(result.contents()).isEqualTo("Contents"); assertThat(result.progress()).isEqualTo(Progress.NOT_STARTED); + assertThat(result.deadLine()).isEqualTo("2024.07.25 13:23"); assertNotNull(result.nickname()); }); - } @DisplayName("블록을 수정합니다.") @@ -74,12 +141,13 @@ void setUp() { when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); // when - BlockInfoResDto result = blockService.update(blockId, blockUpdateReqDto); + BlockInfoResDto result = blockService.update("email", blockId, blockUpdateReqDto); // then assertAll(() -> { assertThat(result.title()).isEqualTo("UpdateTitle"); assertThat(result.contents()).isEqualTo("UpdateContents"); + assertThat(result.deadLine()).isEqualTo("2024.07.28 16:40"); }); } @@ -88,11 +156,12 @@ void setUp() { void 블록_수정_X() { // given Long blockId = 1L; - BlockUpdateReqDto originBlockUpdateReqDto = new BlockUpdateReqDto("Title", "Contents"); + BlockUpdateReqDto originBlockUpdateReqDto = new BlockUpdateReqDto("Title", "Contents", "2024.07.03 13:23", + "2024.07.25 13:23"); when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); // when - BlockInfoResDto result = blockService.update(blockId, originBlockUpdateReqDto); + BlockInfoResDto result = blockService.update("email", blockId, originBlockUpdateReqDto); // then assertAll(() -> { @@ -110,7 +179,7 @@ void setUp() { when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); // when - BlockInfoResDto result = blockService.progressUpdate(blockId, "IN_PROGRESS"); + BlockInfoResDto result = blockService.progressUpdate("email", blockId, "IN_PROGRESS"); // then assertAll(() -> { @@ -128,8 +197,87 @@ void setUp() { when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); // when & then - assertThatThrownBy(() -> blockService.progressUpdate(blockId, "String")) + assertThatThrownBy(() -> blockService.progressUpdate("email", blockId, "String")) .isInstanceOf(InvalidProgressException.class); } + @DisplayName("블록을 삭제 합니다.") + @Test + void 블록_삭제() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + blockService.delete("email", blockId); + + // then + assertAll(() -> { + assertThat(block.getStatus()).isEqualTo(Status.DELETED); + }); + } + + @DisplayName("삭제되었던 블록을 복구합니다.") + @Test + void 블록_복구() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(deleteBlock)); + + // when + blockService.delete("email", blockId); + + // then + assertAll(() -> { + assertThat(deleteBlock.getStatus()).isEqualTo(Status.ACTIVE); + }); + } + + @DisplayName("블록을 상세 봅니다.") + @Test + void 블록_상세보기() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + BlockInfoResDto result = blockService.findById("email", blockId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("Title"); + assertThat(result.contents()).isEqualTo("Contents"); + assertThat(result.progress()).isEqualTo(Progress.NOT_STARTED); + assertThat(result.deadLine()).isEqualTo("2024.07.25 13:23"); + assertNotNull(result.nickname()); + }); + } + + @DisplayName("블록의 순번을 변경합니다.") + @Test + void 블록_순번_변경() { + // given + when(blockRepository.findById(anyLong())).thenReturn(Optional.of(block)); + + // when + blockService.changeBlocksSequence("email", blockSequenceUpdateReqDto); + + // then + verify(blockRepository, times(6)).findById(anyLong()); // 6번 블록 조회가 이루어졌는지 검증 + } + + @DisplayName("블록을 영구 삭제 합니다.") + @Test + void 블록_영구_삭제() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(deleteBlock)); + + // when + blockService.deletePermanently("email", blockId); + + // then + verify(blockRepository, times(1)).delete(deleteBlock); + } + } \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/BlockTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/BlockTest.java new file mode 100644 index 00000000..a7977f27 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/BlockTest.java @@ -0,0 +1,151 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class BlockTest { + private String title; + private String contents; + private String startDate; + private String deadLine; + + private Block block; + + @BeforeEach + void setUp() { + title = "title"; + contents = "contents"; + startDate = "2024.07.03 22:34"; + deadLine = "2024.07.27 22:34"; + + block = Block.builder() + .title(title) + .contents(contents) + .progress(Progress.NOT_STARTED) + .startDate(startDate) + .deadLine(deadLine) + .sequence(1) + .build(); + } + + @DisplayName("블록의 모든 값을 수정합니다.") + @Test + void 블록_수정() { + // given + String updateTitle = "updateTitle"; + String updateContents = "updateContents"; + String updateStartDate = "updateStartDate"; + String updateDeadLine = "updateDeadLine"; + + // when + block.update(updateTitle, updateContents, updateStartDate, updateDeadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(updateTitle); + assertThat(block.getContents()).isEqualTo(updateContents); + assertThat(block.getDeadLine()).isEqualTo(updateDeadLine); + }); + } + + @DisplayName("블록의 제목만 수정합니다.") + @Test + void 블록_제목_수정() { + // given + String updateTitle = "updateTitle"; + + // when + block.update(updateTitle, contents, startDate, deadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(updateTitle); + assertThat(block.getContents()).isEqualTo(contents); + assertThat(block.getDeadLine()).isEqualTo(deadLine); + }); + } + + @DisplayName("블록의 내용만 수정합니다.") + @Test + void 블록_내용_수정() { + // given + String updateContents = "updateContents"; + + // when + block.update(title, updateContents, startDate, deadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(title); + assertThat(block.getContents()).isEqualTo(updateContents); + assertThat(block.getDeadLine()).isEqualTo(deadLine); + }); + } + + @DisplayName("블록의 마감 기한만 수정합니다.") + @Test + void 블록_마감_기한_수정() { + // given + String updateDeadLine = "2024.07.28 22:34"; + + // when + block.update(title, contents, startDate, updateDeadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(title); + assertThat(block.getContents()).isEqualTo(contents); + assertThat(block.getDeadLine()).isEqualTo(updateDeadLine); + }); + } + + @DisplayName("블록의 진행 상태를 수정합니다.") + @Test + void 블록_진행_상태_수정() { + // given + Progress progress = Progress.IN_PROGRESS; + + // when + block.progressUpdate(progress); + + // then + assertThat(block.getProgress()).isEqualTo(progress); + } + + @DisplayName("블록의 논리 삭제 상태를 수정합니다.") + @Test + void 블록_논리_삭제_수정() { + // given + assertThat(block.getStatus()).isEqualTo(Status.ACTIVE); + + // when + block.statusUpdate(); + + // then + assertThat(block.getStatus()).isEqualTo(Status.DELETED); + + // when + block.statusUpdate(); + + // then + assertThat(block.getStatus()).isEqualTo(Status.ACTIVE); + } + + @DisplayName("블록의 순번이 변경됩니다.") + @Test + void 블록_순번_변경() { + // given + + // when + block.sequenceUpdate(5); + + // then + assertThat(block.getSequence()).isEqualTo(5); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepositoryTest.java new file mode 100644 index 00000000..657bee61 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepositoryTest.java @@ -0,0 +1,168 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class BlockRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BlockRepository blockRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + private Member member; + private Dashboard dashboard; + private Block block1; + private Block block2; + private Block block3; + private Block block4; + + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + dashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + + block1 = Block.builder() + .title("title1") + .contents("contents1") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:03") + .dashboard(dashboard) + .sequence(1) + .build(); + + block2 = Block.builder() + .title("title2") + .contents("contents2") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:04") + .dashboard(dashboard) + .sequence(2) + .build(); + + block3 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.IN_PROGRESS) + .deadLine("2024.07.27 11:05") + .dashboard(dashboard) + .sequence(3) + .build(); + + block4 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.COMPLETED) + .deadLine("2024.09.07 12:36") + .dashboard(dashboard) + .sequence(4) + .build(); + + memberRepository.save(member); + dashboardRepository.save(dashboard); + blockRepository.save(block1); + blockRepository.save(block2); + blockRepository.save(block3); + blockRepository.save(block4); + + block4.statusUpdate(); + } + + @DisplayName("블록을 진행 상태별로 전체 조회합니다.") + @Test + void 블록_진행_상태_전체_조회() { + // given + Progress progress = Progress.NOT_STARTED; + Pageable pageable = PageRequest.of(0, 10); + + // when + Page blocks = blockRepository.findByBlockWithProgress(dashboard.getId(), progress, pageable); + + // then + assertThat(blocks.getContent().size()).isEqualTo(2); + } + + @DisplayName("블록을 논리 삭제 상태별로 전체 조회합니다.") + @Test + void 블록_삭제_상태_전체_조회() { + // given + Progress progress = Progress.NOT_STARTED; + Pageable pageable = PageRequest.of(0, 10); + block1.statusUpdate(); + + // when + Page blocks = blockRepository.findByBlockWithProgress(1L, progress, pageable); + + // then + assertThat(blocks.getContent().size()).isEqualTo(1); + } + + @DisplayName("블록의 마지막 순번을 가져옵니다.") + @Test + void 블록_마지막_순번() { + // given + + // when + int lastSequence = blockRepository.findLastSequenceByProgress(member, dashboard.getId(), Progress.NOT_STARTED); + + // then + assertThat(lastSequence).isEqualTo(2); + } + + @DisplayName("논리적으로 삭제된 블록을 조회합니다. ") + @Test + void 삭제_블록_조회() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page blocks = blockRepository.findByDeletedBlocks(dashboard.getId(), pageable); + + // then + assertThat(blocks.getContent().size()).isEqualTo(1); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeControllerTest.java new file mode 100644 index 00000000..4761a577 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeControllerTest.java @@ -0,0 +1,479 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@ExtendWith(RestDocumentationExtension.class) +class ChallengeControllerTest extends ControllerTest { + + private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; + private static final String AUTHORIZATION_HEADER_VALUE = "Bearer valid-token"; + + private Member member; + private Challenge challenge; + private ChallengeSaveReqDto challengeSaveReqDto; + private ChallengeSaveReqDto challengeUpdateReqDto; + private ChallengeSearchReqDto challengeSearchReqDto; + + @InjectMocks + ChallengeController challengeController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + challengeSaveReqDto = new ChallengeSaveReqDto( + "1일 1커밋", + "1일 1커밋하기", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON, CycleDetail.TUE), + LocalDate.now(), + LocalDate.now().plusDays(30), + "대표 이미지"); + + challenge = Challenge.builder() + .title(challengeSaveReqDto.title()) + .contents(challengeSaveReqDto.title()) + .cycleDetails(challengeSaveReqDto.cycleDetails()) + .startDate(challengeSaveReqDto.startDate()) + .endDate(challengeSaveReqDto.endDate()) + .representImage(challengeSaveReqDto.representImage()) + .member(member) + .build(); + + challengeUpdateReqDto = new ChallengeSaveReqDto( + "업데이트 제목", + "업데이트 내용", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON), + LocalDate.now(), + LocalDate.now().plusDays(30), + "업데이트 이미지"); + + challengeSearchReqDto = new ChallengeSearchReqDto("1일"); + + challengeController = new ChallengeController(challengeService); + + mockMvc = MockMvcBuilders.standaloneSetup(challengeController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()).build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))) + .thenReturn("kkeujeok@gmail.com"); + } + + @Test + @DisplayName("챌린지 생성 성공 시 상태코드 201 반환") + void 챌린지_생성_성공_시_상태코드_201_반환() throws Exception { + // given + ChallengeInfoResDto response = ChallengeInfoResDto.from(challenge); + given(challengeService.save(anyString(), any(ChallengeSaveReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + post("/api/challenges") + .header(AUTHORIZATION_HEADER_NAME, + AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(challengeSaveReqDto))) + .andDo(print()) + .andDo(document("challenge/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + requestFields( + fieldWithPath("title").description("챌린지 제목"), + fieldWithPath("contents").description("챌린지 내용"), + fieldWithPath("category").description("챌린지 카테고리"), + fieldWithPath("cycle").description("챌린지 주기"), + fieldWithPath("cycleDetails").description("주기 상세정보"), + fieldWithPath("startDate").description("시작 날짜"), + fieldWithPath("endDate").description("종료 날짜"), + fieldWithPath("representImage").description("대표 사진")), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeId").description("챌린지 id"), + fieldWithPath("data.title").description("챌린지 제목"), + fieldWithPath("data.contents").description("챌린지 내용"), + fieldWithPath("data.category").description("챌린지 카테고리"), + fieldWithPath("data.cycle").description("챌린지 주기"), + fieldWithPath("data.cycleDetails").description("주기 상세정보"), + fieldWithPath("data.startDate").description("시작 날짜"), + fieldWithPath("data.endDate").description("종료 날짜"), + fieldWithPath("data.representImage").description("대표 사진"), + fieldWithPath("data.authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.authorProfileImage").description("챌린지 작성자 프로필 이미지") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("블록 수정에 성공하면 상태코드 200 반환") + void 블록_수정에_성공하면_상태코드_200_반환() throws Exception { + // given + challenge.update(challengeUpdateReqDto.title(), + challengeUpdateReqDto.contents(), + challengeUpdateReqDto.cycleDetails(), + challengeUpdateReqDto.startDate(), + challengeUpdateReqDto.endDate(), + challengeUpdateReqDto.representImage()); + ChallengeInfoResDto response = ChallengeInfoResDto.from(challenge); + given(challengeService.update(anyString(), anyLong(), any(ChallengeSaveReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + patch("/api/challenges/{challengeId}", 1L) + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(challengeSaveReqDto))) + .andDo(print()) + .andDo(document("challenge/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + pathParameters(parameterWithName("challengeId").description("챌린지 ID")), + requestFields( + fieldWithPath("title").description("챌린지 제목"), + fieldWithPath("contents").description("챌린지 내용"), + fieldWithPath("category").description("챌린지 카테고리"), + fieldWithPath("cycle").description("챌린지 주기"), + fieldWithPath("cycleDetails").description("주기 상세정보"), + fieldWithPath("startDate").description("시작 날짜"), + fieldWithPath("endDate").description("종료 날짜"), + fieldWithPath("representImage").description("대표 사진")), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeId").description("챌린지 id"), + fieldWithPath("data.title").description("챌린지 제목"), + fieldWithPath("data.contents").description("챌린지 내용"), + fieldWithPath("data.category").description("챌린지 카테고리"), + fieldWithPath("data.cycle").description("챌린지 주기"), + fieldWithPath("data.cycleDetails").description("주기 상세정보"), + fieldWithPath("data.startDate").description("시작 날짜"), + fieldWithPath("data.endDate").description("종료 날짜"), + fieldWithPath("data.representImage").description("대표 사진"), + fieldWithPath("data.authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.authorProfileImage").description("챌린지 작성자 프로필 이미지")))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 전체 조회에 성공하면 상태코드 200 반환") + void 챌린지_전체_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto challengeInfoResDto = ChallengeInfoResDto.from(challenge); + Page challengePage = new PageImpl<>(List.of(challenge), + PageRequest.of(0, 10), 1); + ChallengeListResDto response = ChallengeListResDto.of(List.of(challengeInfoResDto), + PageInfoResDto.from(challengePage)); + given(challengeService.findAllChallenges(any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/findAll", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeInfoResDto[].challengeId").description("챌린지 id"), + fieldWithPath("data.challengeInfoResDto[].title").description("챌린지 제목"), + fieldWithPath("data.challengeInfoResDto[].contents").description("챌린지 내용"), + fieldWithPath("data.challengeInfoResDto[].category").description("챌린지 카테고리"), + fieldWithPath("data.challengeInfoResDto[].cycle").description("챌린지 주기"), + fieldWithPath("data.challengeInfoResDto[].cycleDetails[]").description("주기 상세정보"), + fieldWithPath("data.challengeInfoResDto[].startDate").description("시작 날짜"), + fieldWithPath("data.challengeInfoResDto[].endDate").description("종료 날짜"), + fieldWithPath("data.challengeInfoResDto[].representImage").description("대표 사진"), + fieldWithPath("data.challengeInfoResDto[].authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.challengeInfoResDto[].authorProfileImage").description( + "챌린지 작성자 프로필 이미지"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + + ))).andExpect(status().isOk()); + } + + @Test + @DisplayName("검색에 성공하면 상태코드 200 반환") + void 검색에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto challengeInfoResDto = ChallengeInfoResDto.from(challenge); + Page challengePage = new PageImpl<>(List.of(challenge), + PageRequest.of(0, 10), 1); + ChallengeListResDto response = ChallengeListResDto.of(List.of(challengeInfoResDto), + PageInfoResDto.from(challengePage)); + given(challengeService.findChallengesByKeyWord(any(ChallengeSearchReqDto.class), any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges/search?keyword=%s", + challengeSearchReqDto.keyWord()) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/search", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeInfoResDto[].challengeId").description("챌린지 id"), + fieldWithPath("data.challengeInfoResDto[].title").description("챌린지 제목"), + fieldWithPath("data.challengeInfoResDto[].contents").description("챌린지 내용"), + fieldWithPath("data.challengeInfoResDto[].category").description("챌린지 카테고리"), + fieldWithPath("data.challengeInfoResDto[].cycle").description("챌린지 주기"), + fieldWithPath("data.challengeInfoResDto[].cycleDetails[]").description("주기 상세정보"), + fieldWithPath("data.challengeInfoResDto[].startDate").description("시작 날짜"), + fieldWithPath("data.challengeInfoResDto[].endDate").description("종료 날짜"), + fieldWithPath("data.challengeInfoResDto[].representImage").description("대표 사진"), + fieldWithPath("data.challengeInfoResDto[].authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.challengeInfoResDto[].authorProfileImage") + .description("챌린지 작성자 프로필 이미지"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 카테고리 별 검색에 성공하면 상태코드 200 반환") + void 챌린지_카테고리_별_검색에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto challengeInfoResDto = ChallengeInfoResDto.from(challenge); + Page challengePage = new PageImpl<>(List.of(challenge), + PageRequest.of(0, 10), 1); + ChallengeListResDto response = ChallengeListResDto.of(List.of(challengeInfoResDto), + PageInfoResDto.from(challengePage)); + + given(challengeService.findByCategory(anyString(), any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges/find?category=%s", + Category.CREATIVITY_AND_ARTS) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/category", preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeInfoResDto[].challengeId").description("챌린지 id"), + fieldWithPath("data.challengeInfoResDto[].title").description("챌린지 제목"), + fieldWithPath("data.challengeInfoResDto[].contents").description("챌린지 내용"), + fieldWithPath("data.challengeInfoResDto[].category").description("챌린지 카테고리"), + fieldWithPath("data.challengeInfoResDto[].cycle").description("챌린지 주기"), + fieldWithPath("data.challengeInfoResDto[].cycleDetails[]").description("주기 상세정보"), + fieldWithPath("data.challengeInfoResDto[].startDate").description("시작 날짜"), + fieldWithPath("data.challengeInfoResDto[].endDate").description("종료 날짜"), + fieldWithPath("data.challengeInfoResDto[].representImage").description("대표 사진"), + fieldWithPath("data.challengeInfoResDto[].authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.challengeInfoResDto[].authorProfileImage") + .description("챌린지 작성자 프로필 이미지"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + )) + ).andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 상세 정보 조회에 성공하면 상태코드 200 반환") + void 챌린지_상세_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto response = ChallengeInfoResDto.from(challenge); + given(challengeService.findById(anyLong())) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges/{challengeId}", 1L) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("challengeId").description("챌린지 ID")), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeId").description("챌린지 id"), + fieldWithPath("data.title").description("챌린지 제목"), + fieldWithPath("data.contents").description("챌린지 내용"), + fieldWithPath("data.category").description("챌린지 카테고리"), + fieldWithPath("data.cycle").description("챌린지 주기"), + fieldWithPath("data.cycleDetails").description("주기 상세정보"), + fieldWithPath("data.startDate").description("시작 날짜"), + fieldWithPath("data.endDate").description("종료 날짜"), + fieldWithPath("data.representImage").description("대표 사진"), + fieldWithPath("data.authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.authorProfileImage").description("챌린지 작성자 프로필 이미지") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 삭제에 성공하면 상태코드 200 반환") + void 챌린지_삭제에_성공하면_상태코드_200_반환() throws Exception { + // given + willDoNothing().given(challengeService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/challenges/{challengeId}", 1L) + .header(AUTHORIZATION_HEADER_NAME, + AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(challengeSaveReqDto))) + .andDo(print()) + .andDo(document("challenge/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + pathParameters(parameterWithName("challengeId").description("챌린지 ID") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지를 개인 대시보드에 추가 성공 시 상태코드 200 반환") + void 챌린지를_개인_대시보드에_추가_성공_시_상태코드_200_반환() throws Exception { + // given + BlockInfoResDto blockInfoResDto = new BlockInfoResDto(1L, + "1일 1커밋", + "1일 1커밋하기", + Progress.NOT_STARTED, + Type.CHALLENGE, + "PersonalDashboard", + "2024.09.31 23:59", + "2024.09.31 23:59", + "동동", + "picture", + 0); + + given(challengeService.addChallengeToPersonalDashboard(anyString(), anyLong(), anyLong())) + .willReturn(blockInfoResDto); + + // when & then + mockMvc.perform(post("/api/challenges/{challengeId}/{dashboardId}", 1L, 1L) + .header(AUTHORIZATION_HEADER_NAME, + AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blockInfoResDto))) + .andDo(print()) + .andDo(document("challenge/addChallengeToPersonalDashboard", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + pathParameters(parameterWithName("challengeId").description("챌린지 ID"), + parameterWithName("dashboardId").description("대시보드 ID")), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 ID"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행도"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작기한"), + fieldWithPath("data.deadLine").description("블록 마감기한"), + fieldWithPath("data.nickname").description("블록 작성자 닉네임"), + fieldWithPath("data.picture").description("블록 작성자 사진"), + fieldWithPath("data.dDay").description("블록 디데이") + )) + ) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeServiceTest.java new file mode 100644 index 00000000..b42632f4 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeServiceTest.java @@ -0,0 +1,399 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.repository.ChallengeRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.ChallengeAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@ExtendWith(MockitoExtension.class) +class ChallengeServiceTest { + + private Member member; + private Challenge challenge; + private ChallengeSaveReqDto updateDto; + private ChallengeSaveReqDto challengeSaveReqDto; + private PersonalDashboard personalDashboard; + private Block block; + private PersonalDashboardSaveReqDto personalDashboardSaveReqDto; + + + @Mock + private ChallengeRepository challengeRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private PersonalDashboardRepository personalDashboardRepository; + + @Mock + private BlockRepository blockRepository; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private ChallengeService challengeService; + + + @BeforeEach + void setUp() { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + lenient().when(memberRepository.findByEmail(anyString())) + .thenReturn(Optional.ofNullable(member)); + + challengeSaveReqDto = new ChallengeSaveReqDto( + "1일 1커밋", + "1일 1커밋하기", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON, CycleDetail.TUE), + LocalDate.now(), + LocalDate.now().plusDays(30), + "대표 이미지" + ); + + challenge = Challenge.builder() + .title(challengeSaveReqDto.title()) + .contents(challengeSaveReqDto.contents()) + .cycle(challengeSaveReqDto.cycle()) + .cycleDetails(challengeSaveReqDto.cycleDetails()) + .startDate(challengeSaveReqDto.startDate()) + .endDate(challengeSaveReqDto.endDate()) + .representImage(challengeSaveReqDto.representImage()) + .member(member) + .build(); + + updateDto = new ChallengeSaveReqDto( + "업데이트 제목", + "업데이트 내용", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON), + LocalDate.now(), + LocalDate.now().plusDays(30), + "업데이트 이미지" + ); + + block = Block.builder() + .title(challenge.getTitle()) + .contents(challenge.getContents()) + .progress(Progress.NOT_STARTED) + .type(Type.CHALLENGE) + .deadLine(LocalDate.now() + .format(DateTimeFormatter.ofPattern("yyyy.MM.dd 23:59"))) + .member(member) + .dashboard(personalDashboard) + .challenge(challenge) + .build(); + + personalDashboardSaveReqDto = new PersonalDashboardSaveReqDto("개인 대시보드", + "테스트용 대시보드", + false, + "category"); + + personalDashboard = PersonalDashboard.builder() + .title(personalDashboardSaveReqDto.title()) + .description(personalDashboardSaveReqDto.description()) + .isPublic(personalDashboardSaveReqDto.isPublic()) + .category(personalDashboardSaveReqDto.category()) + .member(member) + .build(); + } + + @Test + @DisplayName("인증된 회원은 챌린지를 생성할 수 있다") + void 인증된_회원은_챌린지를_생성할_수_있다() { + + // given + when(challengeRepository.save(any(Challenge.class))) + .thenReturn(challenge); + // when + ChallengeInfoResDto result = challengeService.save(member.getEmail(), challengeSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("1일 1커밋"); + assertThat(result.contents()).isEqualTo("1일 1커밋하기"); + assertThat(result.cycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(result.startDate()).isEqualTo(LocalDate.now()); + assertThat(result.endDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(result.representImage()).isEqualTo("대표 이미지"); + assertThat(result.authorName()).isEqualTo("동동"); + assertThat(result.authorProfileImage()).isEqualTo("기본 프로필"); + }); + } + + @Test + @DisplayName("주기 정보와 세부 주기가 일치하지 않는다면 예외가 발생한다") + void 주기_정보와_세부_주기가_일치하지_않는다면_예외가_발생한다() { + // given + ChallengeSaveReqDto wrongChallengeSaveReqDto = new ChallengeSaveReqDto( + "1일 1커밋", + "1일 1커밋하기", + Category.CREATIVITY_AND_ARTS, + Cycle.MONTHLY, + List.of(CycleDetail.MON, CycleDetail.TUE), + LocalDate.now(), + LocalDate.now().plusDays(30), + "대표 이미지" + ); + + // when & then + assertThatThrownBy(() -> challengeService.save(member.getEmail(), wrongChallengeSaveReqDto)) + .isInstanceOf(InvalidCycleException.class); + } + + @Test + @DisplayName("회원 정보가 없다면 챌린지를 생성하려 하면 예외가 발생한다") + void 회원_정보가_없다면_챌린지를_생성하려_하면_예외가_발생한다() { + // given + String errorEmail = "존재하지 않는 이메일@example.com"; + + when(memberRepository.findByEmail(errorEmail)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> challengeService.save(errorEmail, challengeSaveReqDto)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + @DisplayName("챌린지 작성자는 챌린지 정보를 업데이트할 수 있다") + void 챌린지_작성자는_챌린지_정보를_업데이트할_수_있다() { + // given + Long challengeId = 1L; + when(challengeRepository.findById(challengeId)) + .thenReturn(Optional.of(challenge)); + + // when + ChallengeInfoResDto result = challengeService.update(member.getEmail(), challengeId, updateDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("업데이트 제목"); + assertThat(result.contents()).isEqualTo("업데이트 내용"); + assertThat(result.cycleDetails()).containsExactly(CycleDetail.MON); + assertThat(result.startDate()).isEqualTo(LocalDate.now()); + assertThat(result.endDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(result.representImage()).isEqualTo("업데이트 이미지"); + }); + } + + @Test + @DisplayName("챌린지 작성자가 아니면 수정 시 예외가 발생한다") + void 챌린지_작성자가_아니면_수정_시_예외가_발생한다() { + // given + Long challengeId = 1L; + Member otherMember = Member.builder() + .status(Status.ACTIVE) + .email("other@example.com") + .name("다른 사용자") + .picture("다른 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("달라") + .build(); + + when(challengeRepository.findById(challengeId)) + .thenReturn(Optional.of(challenge)); + when(memberRepository.findByEmail("other@example.com")) + .thenReturn(Optional.of(otherMember)); + + // when & then + assertThatThrownBy(() -> challengeService.update("other@example.com", challengeId, updateDto)) + .isInstanceOf(ChallengeAccessDeniedException.class); + } + + @Test + @DisplayName("모든챌린지를 조회할 수 있다") + void 모든_챌린지를_조회할_수_있다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(challenge), PageRequest.of(0, 10), 1); + when(challengeRepository.findAllChallenges(any(Pageable.class))) + .thenReturn(page); + + // when + ChallengeListResDto result = challengeService.findAllChallenges(pageable); + + // then + assertAll(() -> { + assertThat(result.challengeInfoResDto().size()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalPages()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(1); + }); + } + + @Test + @DisplayName("챌린지 목록을 검색할 수 있다") + void 챌린지_목록을_검색할_수_있다() { + // given + Pageable pageable = PageRequest.of(0, 10); + ChallengeSearchReqDto searchReqDto = ChallengeSearchReqDto.from("1일"); + Page page = new PageImpl<>(List.of(challenge), pageable, 1); + when(challengeRepository.findChallengesByKeyWord(any(ChallengeSearchReqDto.class), any(PageRequest.class))) + .thenReturn(page); + + // when + ChallengeListResDto result = challengeService.findChallengesByKeyWord(searchReqDto, pageable); + + // then + assertAll(() -> { + assertThat(result.challengeInfoResDto().size()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalPages()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(1); + }); + } + + @Test + @DisplayName("챌린지를 카테고리 별로 검색할 수 있다") + void 챌린지를_카테고리_별로_검색할_수_있다() { + //given + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(challenge), pageable, 1); + when(challengeRepository.findChallengesByCategory(anyString(), any(PageRequest.class))) + .thenReturn(page); + + // when + ChallengeListResDto result = challengeService.findByCategory("CREATIVITY_AND_ARTS", pageable); + + // then + assertAll(() -> { + assertThat(result.challengeInfoResDto().size()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalPages()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(1); + }); + } + + @Test + @DisplayName("챌린지 상세정보를 조회할 수 있다") + void 챌린지_상세정보를_조회할_수_있다() { + // given + Long challengeId = 1L; + when(challengeRepository.findById(challengeId)).thenReturn(Optional.of(challenge)); + + // when + ChallengeInfoResDto result = challengeService.findById(challengeId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("1일 1커밋"); + assertThat(result.contents()).isEqualTo("1일 1커밋하기"); + assertThat(result.cycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(result.startDate()).isEqualTo(LocalDate.now()); + assertThat(result.endDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(result.representImage()).isEqualTo("대표 이미지"); + assertThat(result.authorName()).isEqualTo("동동"); + assertThat(result.authorProfileImage()).isEqualTo("기본 프로필"); + }); + } + + @Test + @DisplayName("챌린지 작성자가 아니면 삭제할 수 없다") + void 챌린지_작성자가_아니면_삭제할_수_없다() { + // given + Long challengeId = 1L; + Member otherMember = Member.builder() + .status(Status.ACTIVE) + .email("other@example.com") + .name("다른 사용자") + .picture("다른 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("달라") + .build(); + when(challengeRepository.findById(challengeId)) + .thenReturn(Optional.of(challenge)); + when(memberRepository.findByEmail("other@example.com")) + .thenReturn(Optional.of(otherMember)); + + // when & then + assertThatThrownBy(() -> challengeService.delete("other@example.com", challengeId)) + .isInstanceOf(ChallengeAccessDeniedException.class); + } + + @Test + @DisplayName("챌린지를 개인 대시보드에 추가할 수 있다") + void 챌린지를_개인_대시보드에_추가할_수_있다() { + // given + Long personalDashboardId = 1L; + Long challengeId = 1L; + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + when(challengeRepository.findById(anyLong())).thenReturn(Optional.of(challenge)); + when(personalDashboardRepository.findById(anyLong())).thenReturn(Optional.of(personalDashboard)); + when(blockRepository.save(any(Block.class))).thenReturn(block); + + // when + BlockInfoResDto result = challengeService.addChallengeToPersonalDashboard(member.getEmail(), + personalDashboardId, challengeId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("1일 1커밋"); + assertThat(result.contents()).isEqualTo("1일 1커밋하기"); + assertThat(result.progress()).isEqualTo(Progress.NOT_STARTED); + assertThat(result.deadLine()).isEqualTo( + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd 23:59"))); + }); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtilTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtilTest.java new file mode 100644 index 00000000..630f4995 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtilTest.java @@ -0,0 +1,89 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; + +class ChallengeBlockStatusUtilTest { + + @Test + @DisplayName("DAILY 주기는 항상 true를 반환한다") + void DAILY_주기는_항상_true를_반환한다() { + // given + List cycleDetails = List.of(CycleDetail.DAILY); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.DAILY, cycleDetails); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("WEEKLY 주기에서 오늘의 요일이 포함되어 있으면 true를 반환한다") + void WEEKLY_주기에서_오늘의_요일이_포함되어_있으면_true를_반환한다() { + // given + List activeDays = List.of(CycleDetail.MON, CycleDetail.TUE, CycleDetail.WED, CycleDetail.THU, + CycleDetail.FRI, CycleDetail.SAT, CycleDetail.SUN); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.WEEKLY, activeDays); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("WEEKLY 주기에서 오늘의 요일이 포함되지 않으면 false를 반환한다") + void WEEKLY_주기에서_오늘의_요일이_포함되지_않으면_비활성_상태여야_한다() { + // given + List activeDays = List.of(); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.WEEKLY, activeDays); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("MONTHLY 주기에서 오늘의 날짜가 포함되어 있으면 true를 반환한다.") + void MONTHLY_주기에서_오늘의_날짜가_포함되어_있으면_true를_반환한다() { + // given + List activeDays = List.of( + CycleDetail.FIRST, CycleDetail.SECOND, CycleDetail.THIRD, CycleDetail.FOURTH, CycleDetail.FIFTH, + CycleDetail.SIXTH, CycleDetail.SEVENTH, CycleDetail.EIGHTH, CycleDetail.NINTH, CycleDetail.TENTH, + CycleDetail.ELEVENTH, CycleDetail.TWELFTH, CycleDetail.THIRTEENTH, CycleDetail.FOURTEENTH, + CycleDetail.FIFTEENTH, + CycleDetail.SIXTEENTH, CycleDetail.SEVENTEENTH, CycleDetail.EIGHTEENTH, CycleDetail.NINETEENTH, + CycleDetail.TWENTIETH, + CycleDetail.TWENTY_FIRST, CycleDetail.TWENTY_SECOND, CycleDetail.TWENTY_THIRD, + CycleDetail.TWENTY_FOURTH, CycleDetail.TWENTY_FIFTH, + CycleDetail.TWENTY_SIXTH, CycleDetail.TWENTY_SEVENTH, CycleDetail.TWENTY_EIGHTH, + CycleDetail.TWENTY_NINTH, CycleDetail.THIRTIETH, + CycleDetail.THIRTY_FIRST); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.MONTHLY, activeDays); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("MONTHLY 주기에서 오늘의 날짜가 포함되지 않으면 false를 반환한다") + void MONTHLY_주기에서_오늘의_날짜가_포함되지_않으면_false를_반환한다() { + // given + List activeDays = List.of(); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.MONTHLY, activeDays); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverterTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverterTest.java new file mode 100644 index 00000000..0e377afe --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverterTest.java @@ -0,0 +1,66 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleDetailsConversionException; + +class CycleDetailsConverterTest { + + private CycleDetailsConverter converter; + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + converter = new CycleDetailsConverter(); + mapper = new ObjectMapper(); + } + + @Test + @DisplayName("유효한 리스트를 JSON 문자열로 변환할 수 있다") + void 유효한_리스트를_JSON_문자열로_변환할_수_있다() throws JsonProcessingException { + // given + List cycleDetails = Arrays.asList(CycleDetail.MON, CycleDetail.TUE, CycleDetail.WED); + + // when + String json = converter.convertToDatabaseColumn(cycleDetails); + + // then + String expectedJson = mapper.writeValueAsString(cycleDetails); + assertThat(json).isEqualTo(expectedJson); + } + + @Test + @DisplayName("유효한 JSON 문자열을 리스트로 변환할 수 있다") + void 유효한_JSON_문자열을_리스트로_변환할_수_있다() throws JsonProcessingException { + // given + List expectedList = Arrays.asList(CycleDetail.MON, CycleDetail.TUE, CycleDetail.WED); + String jsonString = mapper.writeValueAsString(expectedList); + + // when + List result = converter.convertToEntityAttribute(jsonString); + + // then + assertThat(result).isEqualTo(expectedList); + } + + @Test + @DisplayName("유효하지 않은 JSON 문자열을 변환하려 할 때 예외가 발생한다") + void 유효하지_않은_JSON_문자열을_변환하려_할_때_예외가_발생한다() { + // given + String invalidJsonString = "invalid json"; + + // when & then + assertThatThrownBy(() -> converter.convertToEntityAttribute(invalidJsonString)) + .isInstanceOf(InvalidCycleDetailsConversionException.class) + .hasMessageContaining("JSON 문자열을 List로 변환하는 중 오류가 발생했습니다."); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/ChallengeTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/ChallengeTest.java new file mode 100644 index 00000000..8d7977e4 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/ChallengeTest.java @@ -0,0 +1,152 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class ChallengeTest { + private Challenge challenge; + + @BeforeEach + void setUp() { + Member member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("프로필 사진") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + challenge = Challenge.builder() + .status(Status.ACTIVE) + .title("제목") + .contents("내용") + .cycleDetails(List.of(CycleDetail.MON, CycleDetail.TUE)) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(30)) + .representImage("대표 사진") + .member(member) + .build(); + } + + @Test + @DisplayName("챌린지의 모든 필드를 수정합니다.") + void 챌린지_수정() { + // given + String updateTitle = "수정된 제목"; + String updateContents = "수정된 내용"; + List updateCycleDetails = List.of(CycleDetail.WED, CycleDetail.THU); + LocalDate updateStartDate = LocalDate.now().plusDays(1); + LocalDate updateEndDate = LocalDate.now().plusDays(31); + String updateRepresentImage = "수정된 대표 사진"; + + // when + challenge.update(updateTitle, updateContents, updateCycleDetails, updateStartDate, updateEndDate, + updateRepresentImage); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo(updateTitle); + assertThat(challenge.getContents()).isEqualTo(updateContents); + assertThat(challenge.getCycleDetails()).isEqualTo(updateCycleDetails); + assertThat(challenge.getStartDate()).isEqualTo(updateStartDate); + assertThat(challenge.getEndDate()).isEqualTo(updateEndDate); + assertThat(challenge.getRepresentImage()).isEqualTo(updateRepresentImage); + }); + } + + @Test + @DisplayName("챌린지의 제목만 수정합니다.") + void 챌린지_제목_수정() { + // given + String updateTitle = "수정된 제목"; + + // when + challenge.update(updateTitle, challenge.getContents(), challenge.getCycleDetails(), challenge.getStartDate(), + challenge.getEndDate(), challenge.getRepresentImage()); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo(updateTitle); + assertThat(challenge.getContents()).isEqualTo("내용"); + assertThat(challenge.getCycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(challenge.getStartDate()).isEqualTo(LocalDate.now()); + assertThat(challenge.getEndDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(challenge.getRepresentImage()).isEqualTo("대표 사진"); + }); + } + + @Test + @DisplayName("챌린지의 내용만 수정합니다.") + void 챌린지_내용_수정() { + // given + String updateContents = "수정된 내용"; + + // when + challenge.update(challenge.getTitle(), updateContents, challenge.getCycleDetails(), challenge.getStartDate(), + challenge.getEndDate(), challenge.getRepresentImage()); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo("제목"); + assertThat(challenge.getContents()).isEqualTo(updateContents); + assertThat(challenge.getCycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(challenge.getStartDate()).isEqualTo(LocalDate.now()); + assertThat(challenge.getEndDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(challenge.getRepresentImage()).isEqualTo("대표 사진"); + }); + } + + @Test + @DisplayName("챌린지의 마감일만 수정합니다.") + void 챌린지_마감일_수정() { + // given + LocalDate updateEndDate = LocalDate.now().plusDays(40); + + // when + challenge.update(challenge.getTitle(), challenge.getContents(), challenge.getCycleDetails(), + challenge.getStartDate(), updateEndDate, challenge.getRepresentImage()); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo("제목"); + assertThat(challenge.getContents()).isEqualTo("내용"); + assertThat(challenge.getCycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(challenge.getStartDate()).isEqualTo(LocalDate.now()); + assertThat(challenge.getEndDate()).isEqualTo(updateEndDate); + assertThat(challenge.getRepresentImage()).isEqualTo("대표 사진"); + }); + } + + @Test + @DisplayName("챌지의 상태를 수정합니다.") + void 챌린지_상태_수정() { + // given + assertThat(challenge.getStatus()).isEqualTo(Status.ACTIVE); + + // when + challenge.updateStatus(); + + // then + assertThat(challenge.getStatus()).isEqualTo(Status.DELETED); + + // when + challenge.updateStatus(); + + // then + assertThat(challenge.getStatus()).isEqualTo(Status.ACTIVE); + } +} + diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/common/annotation/ControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/common/annotation/ControllerTest.java new file mode 100644 index 00000000..7d6eb289 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/common/annotation/ControllerTest.java @@ -0,0 +1,95 @@ +package shop.kkeujeok.kkeujeokbackend.common.annotation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import shop.kkeujeok.kkeujeokbackend.auth.api.AuthController; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthMemberService; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthServiceFactory; +import shop.kkeujeok.kkeujeokbackend.auth.application.TokenService; +import shop.kkeujeok.kkeujeokbackend.block.api.BlockController; +import shop.kkeujeok.kkeujeokbackend.block.application.BlockService; +import shop.kkeujeok.kkeujeokbackend.challenge.api.ChallengeController; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.application.PersonalDashboardService; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.DocumentService; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.FileService; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; +import shop.kkeujeok.kkeujeokbackend.member.api.MemberControllerTest; +import shop.kkeujeok.kkeujeokbackend.member.mypage.application.MyPageService; +import shop.kkeujeok.kkeujeokbackend.member.nickname.application.NicknameService; +import shop.kkeujeok.kkeujeokbackend.notification.api.NotificationController; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; +import shop.kkeujeok.kkeujeokbackend.notification.util.SseEmitterManager; + +@AutoConfigureRestDocs +@WebMvcTest({ + BlockController.class, + AuthController.class, + ChallengeController.class, + MemberControllerTest.class, + NotificationController.class +}) +@ExtendWith(RestDocumentationExtension.class) +@ActiveProfiles("test") +public abstract class ControllerTest { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected BlockService blockService; + + @MockBean + protected PersonalDashboardService personalDashboardService; + + @MockBean + protected TeamDashboardService teamDashboardService; + + @MockBean + protected TokenProvider tokenProvider; + + @MockBean + protected AuthServiceFactory authServiceFactory; + + @MockBean + protected AuthMemberService authMemberService; + + @MockBean + protected TokenService tokenService; + + @MockBean + protected AuthService authService; + + @MockBean + protected ChallengeService challengeService; + + @MockBean + protected NicknameService nicknameService; + + @MockBean + protected MyPageService myPageService; + + @MockBean + protected FileService fileService; + + @MockBean + protected DocumentService documentService; + + @MockBean + protected NotificationService notificationService; + + @MockBean + protected SseEmitterManager sseEmitterManager; +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardControllerTest.java new file mode 100644 index 00000000..1c78eaba --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardControllerTest.java @@ -0,0 +1,321 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class PersonalDashboardControllerTest extends ControllerTest { + + private Member member; + private PersonalDashboard personalDashboard; + private PersonalDashboardSaveReqDto personalDashboardSaveReqDto; + private PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto; + + @InjectMocks + private PersonalDashboardController personalDashboardController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + personalDashboardSaveReqDto = new PersonalDashboardSaveReqDto("title", "description", false, "category"); + personalDashboardUpdateReqDto = new PersonalDashboardUpdateReqDto("updateTitle", "updateDescription", true, + "updateCategory"); + personalDashboard = personalDashboardSaveReqDto.toEntity(member); + + ReflectionTestUtils.setField(personalDashboard, "id", 1L); + ReflectionTestUtils.setField(member, "id", 1L); + ReflectionTestUtils.setField(personalDashboard, "member", member); + + personalDashboardController = new PersonalDashboardController(personalDashboardService); + + mockMvc = MockMvcBuilders.standaloneSetup(personalDashboardController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + } + + @DisplayName("POST 개인 대시보드 저장 컨트롤러 로직 확인") + @Test + void 개인_대시보드_저장() throws Exception { + // given + PersonalDashboardInfoResDto response = PersonalDashboardInfoResDto.of(member, personalDashboard); + given(personalDashboardService.save(anyString(), any(PersonalDashboardSaveReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/dashboards/personal/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(personalDashboardSaveReqDto))) + .andDo(print()) + .andDo(document("dashboard/personal/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("title").description("개인 대시보드 제목"), + fieldWithPath("description").description("개인 대시보드 설명"), + fieldWithPath("isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("category").description("개인 대시보드 카테고리") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("개인 대시보드 제목"), + fieldWithPath("data.description").description("개인 대시보드 설명"), + fieldWithPath("data.isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("data.category").description("개인 대시보드 카테고리"), + fieldWithPath("data.blockProgress").description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 개인 대시보드 수정 컨트롤러 로직 확인") + @Test + void 개인_대시보드_수정() throws Exception { + // given + personalDashboard.update(personalDashboardUpdateReqDto.title(), + personalDashboardUpdateReqDto.description(), + personalDashboardUpdateReqDto.isPublic(), + personalDashboardUpdateReqDto.category()); + PersonalDashboardInfoResDto response = PersonalDashboardInfoResDto.of(member, personalDashboard); + given(personalDashboardService.update(anyString(), anyLong(), + any(PersonalDashboardUpdateReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/dashboards/personal/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(personalDashboardUpdateReqDto))) + .andDo(print()) + .andDo(document("dashboard/personal/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 ID") + ), + requestFields( + fieldWithPath("title").description("개인 대시보드 제목"), + fieldWithPath("description").description("개인 대시보드 설명"), + fieldWithPath("isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("category").description("개인 대시보드 카테고리") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("개인 대시보드 제목"), + fieldWithPath("data.description").description("개인 대시보드 설명"), + fieldWithPath("data.isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("data.category").description("개인 대시보드 카테고리"), + fieldWithPath("data.blockProgress").description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 개인 대시보드를 전체 조회합니다.") + @Test + void 개인_대시보드_전체_조회() throws Exception { + // given + PersonalDashboardListResDto response = PersonalDashboardListResDto.of( + Collections.singletonList(PersonalDashboardInfoResDto.of(member, personalDashboard)) + ); + + given(personalDashboardService.findForPersonalDashboard(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/personal/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/findForPersonalDashboard", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.personalDashboardListResDto[].dashboardId") + .description("대시보드 아이디"), + fieldWithPath("data.personalDashboardListResDto[].myId") + .description("내 아이디"), + fieldWithPath("data.personalDashboardListResDto[].creatorId") + .description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.personalDashboardListResDto[].title") + .description("개인 대시보드 제목"), + fieldWithPath("data.personalDashboardListResDto[].description") + .description("개인 대시보드 설명"), + fieldWithPath("data.personalDashboardListResDto[].isPublic") + .description("개인 대시보드 공개 범위"), + fieldWithPath("data.personalDashboardListResDto[].category") + .description("개인 대시보드 카테고리"), + fieldWithPath("data.personalDashboardListResDto[].blockProgress") + .description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 개인 대시보드를 상세봅니다.") + @Test + void 개인_대시보드_상세보기() throws Exception { + // given + PersonalDashboardInfoResDto response = PersonalDashboardInfoResDto.of(member, personalDashboard); + + given(personalDashboardService.findById(anyString(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/personal/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 아이디") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("개인 대시보드 제목"), + fieldWithPath("data.description").description("개인 대시보드 설명"), + fieldWithPath("data.isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("data.category").description("개인 대시보드 카테고리"), + fieldWithPath("data.blockProgress").description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 사용자 개인 대시보드들의 카테고리를 불러옵니다.") + @Test + void 개인_대시보드_카테고리_조회() throws Exception { + // given + PersonalDashboardCategoriesResDto response = PersonalDashboardCategoriesResDto.from(Set.of("category")); + + given(personalDashboardService.findCategoriesForDashboard(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/personal/categories") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/categories", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.categories[]").description("개인 대시보드 카테고리") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 개인 대시보드를 논리적으로 삭제하고 복구합니다.") + @Test + void 개인_대시보드_삭제() throws Exception { + // given + doNothing().when(personalDashboardService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/dashboards/personal/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("개인 대시보드 ID") + + ) + )) + .andExpect(status().isOk()); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardServiceTest.java new file mode 100644 index 00000000..aa1e3aa9 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardServiceTest.java @@ -0,0 +1,238 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class PersonalDashboardServiceTest { + + @Mock + private PersonalDashboardRepository personalDashboardRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private PersonalDashboardService personalDashboardService; + + private Member member; + private PersonalDashboard personalDashboard; + private PersonalDashboard deletePersonalDashboard; + private PersonalDashboardSaveReqDto personalDashboardSaveReqDto; + private PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.ofNullable(member)); + + personalDashboardSaveReqDto = new PersonalDashboardSaveReqDto("title", "description", false, "category"); + personalDashboardUpdateReqDto = new PersonalDashboardUpdateReqDto( + "updateTitle", + "updateDescription", + true, + "updateCategory"); + + personalDashboard = PersonalDashboard.builder() + .title(personalDashboardSaveReqDto.title()) + .description(personalDashboardSaveReqDto.description()) + .isPublic(personalDashboardSaveReqDto.isPublic()) + .category(personalDashboardSaveReqDto.category()) + .member(member) + .build(); + + deletePersonalDashboard = PersonalDashboard.builder() + .title(personalDashboardSaveReqDto.title()) + .description(personalDashboardSaveReqDto.description()) + .isPublic(personalDashboardSaveReqDto.isPublic()) + .category(personalDashboardSaveReqDto.category()) + .member(member) + .build(); + deletePersonalDashboard.statusUpdate(); + } + + @DisplayName("개인 대시보드를 저장합니다.") + @Test + void 개인_대시보드_저장() { + // given + when(personalDashboardRepository.save(any(PersonalDashboard.class))).thenReturn(personalDashboard); + + // when + PersonalDashboardInfoResDto result = personalDashboardService.save( + member.getEmail(), + personalDashboardSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.isPublic()).isEqualTo(false); + assertThat(result.category()).isEqualTo("category"); + }); + } + + @DisplayName("개인 대시보드를 수정합니다.") + @Test + void 개인_대시보드_수정() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when + PersonalDashboardInfoResDto result = personalDashboardService.update( + member.getEmail(), + dashboardId, + personalDashboardUpdateReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("updateTitle"); + assertThat(result.description()).isEqualTo("updateDescription"); + assertThat(result.isPublic()).isEqualTo(true); + assertThat(result.category()).isEqualTo("updateCategory"); + }); + } + + @DisplayName("생성자가 아닌 사용자가 개인 대시보드를 수정하는데 실패합니다. (DashboardAccessDeniedException 발생)") + @Test + void 개인_대시보드_수정_실패() { + // given + Long dashboardId = 1L; + String unauthorizedEmail = "unauthorizedEmail@email.com"; + + Member unauthorizedMember = Member.builder() + .email(unauthorizedEmail) + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(unauthorizedEmail)).thenReturn(Optional.of(unauthorizedMember)); + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when & then + assertThatThrownBy( + () -> personalDashboardService.update(unauthorizedEmail, dashboardId, personalDashboardUpdateReqDto) + ).isInstanceOf(DashboardAccessDeniedException.class); + } + + @DisplayName("개인 대시보드를 삭제합니다.") + @Test + void 개인_대시보드_삭제() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when + personalDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(personalDashboard.getStatus()).isEqualTo(Status.DELETED); + }); + } + + @DisplayName("개인 대시보드를 전체 조회합니다.") + @Test + void 개인_대시보드_전체_조회() { + // given + when(personalDashboardRepository.findForPersonalDashboard(any(Member.class))) + .thenReturn(List.of(personalDashboard)); + + // when + PersonalDashboardListResDto result = personalDashboardService.findForPersonalDashboard(member.getEmail()); + + // then + assertThat(result).isNotNull(); + assertThat(result.personalDashboardListResDto()).hasSize(1); + } + + @DisplayName("개인 대시보드를 상세 봅니다.") + @Test + void 개인_대시보드_상세보기() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when + PersonalDashboardInfoResDto result = personalDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.isPublic()).isEqualTo(false); + assertThat(result.category()).isEqualTo("category"); + assertThat(result.blockProgress()).isEqualTo(0.0); + }); + } + + @DisplayName("개인 대시보드 카테고리를 조회합니다.") + @Test + void 개인_대시보드_카테고리_조회() { + // given + Set categories = Set.of("category"); + when(personalDashboardRepository.findCategoriesForDashboard(any(Member.class))).thenReturn(categories); + + // when + PersonalDashboardCategoriesResDto result = personalDashboardService.findCategoriesForDashboard( + member.getEmail()); + + // then + assertThat(result.categories()).hasSize(1); + } + + @DisplayName("삭제되었던 개인 대시보드를 복구합니다.") + @Test + void 개인_대시보드_복구() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(deletePersonalDashboard)); + + // when + personalDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(deletePersonalDashboard.getStatus()).isEqualTo(Status.ACTIVE); + }); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboardTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboardTest.java new file mode 100644 index 00000000..335ce6c1 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboardTest.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class PersonalDashboardTest { + + private PersonalDashboard personalDashboard; + + @BeforeEach + void setUp() { + personalDashboard = PersonalDashboard.builder() + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + } + + @DisplayName("개인 대시보드의 모든 값을 수정합니다.") + @Test + void 개인_대시보드_수정() { + // given + String updateTitle = "updateTitle"; + String updateDescription = "updateDescription"; + boolean updateIsPublic = true; + String updateCategory = "category"; + + // when + personalDashboard.update(updateTitle, updateDescription, updateIsPublic, updateCategory); + + // then + assertAll(() -> { + assertThat(personalDashboard.getTitle()).isEqualTo(updateTitle); + assertThat(personalDashboard.getDescription()).isEqualTo(updateDescription); + assertThat(personalDashboard.isPublic()).isEqualTo(updateIsPublic); + assertThat(personalDashboard.getCategory()).isEqualTo(updateCategory); + }); + } + + @DisplayName("개인 대시보드 논리 삭제 상태를 수정합니다.") + @Test + void 개인_대시보드_상태_수정() { + // given + assertThat(personalDashboard.getStatus()).isEqualTo(Status.ACTIVE); + + // when + personalDashboard.statusUpdate(); + + // then + assertThat(personalDashboard.getStatus()).isEqualTo(Status.DELETED); + + // when + personalDashboard.statusUpdate(); + + // then + assertThat(personalDashboard.getStatus()).isEqualTo(Status.ACTIVE); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepositoryTest.java new file mode 100644 index 00000000..cc6f7564 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepositoryTest.java @@ -0,0 +1,121 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class PersonalDashboardRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BlockRepository blockRepository; + + @Autowired + private PersonalDashboardRepository personalDashboardRepository; + + private Member member; + private PersonalDashboard personalDashboard; + private Block block1; + private Block block2; + private Block block3; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + memberRepository.save(member); + + personalDashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + + personalDashboardRepository.save(personalDashboard); + + block1 = Block.builder() + .title("title1") + .contents("contents1") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:03") + .dashboard(personalDashboard) + .build(); + + block2 = Block.builder() + .title("title2") + .contents("contents2") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:04") + .dashboard(personalDashboard) + .build(); + + block3 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:05") + .dashboard(personalDashboard) + .build(); + + blockRepository.save(block1); + blockRepository.save(block2); + blockRepository.save(block3); + } + + @DisplayName("개인 대시보드를 전체 조회합니다.") + @Test + void 개인_대시보드_전체_조회() { + // given + + // when + List result = personalDashboardRepository.findForPersonalDashboard(member); + + // then + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getTitle()).isEqualTo("title"); + assertThat(result.get(0).getMember()).isEqualTo(member); + } + + @DisplayName("대시보드의 완료된 블록 비율을 계산합니다.") + @Test + void 개인_대시보드_완료_블록_비율_계산() { + // when + double completionPercentage = personalDashboardRepository + .calculateCompletionPercentage(personalDashboard.getId()); + + // then + assertThat(completionPercentage).isEqualTo(66.67, offset(0.01)); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardControllerTest.java new file mode 100644 index 00000000..5cf567a0 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardControllerTest.java @@ -0,0 +1,379 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +class TeamDashboardControllerTest extends ControllerTest { + + private Member member; + private Member joinMember; + private TeamDashboard teamDashboard; + private TeamDashboardSaveReqDto teamDashboardSaveReqDto; + private TeamDashboardUpdateReqDto teamDashboardUpdateReqDto; + + @InjectMocks + private TeamDashboardController teamDashboardController; + + @Mock + private NotificationService notificationService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + joinMember = Member.builder() + .email("joinEmail") + .name("joinName") + .nickname("joinNickname") + .socialType(SocialType.GOOGLE) + .introduction("joinIntroduction") + .picture("joinPicture") + .build(); + + List emails = List.of("joinEmail"); + teamDashboardSaveReqDto = new TeamDashboardSaveReqDto("title", "description", emails); + teamDashboardUpdateReqDto = new TeamDashboardUpdateReqDto("updateTitle", "updateDescription", emails); + teamDashboard = teamDashboardSaveReqDto.toEntity(member); + + ReflectionTestUtils.setField(teamDashboard, "id", 1L); + ReflectionTestUtils.setField(member, "id", 1L); + ReflectionTestUtils.setField(teamDashboard, "member", member); + + teamDashboardController = new TeamDashboardController(teamDashboardService); + + mockMvc = MockMvcBuilders.standaloneSetup(teamDashboardController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + } + + @DisplayName("POST 팀 대시보드 저장 컨트롤러 로직 확인") + @Test + void 팀_대시보드_저장() throws Exception { + // given + TeamDashboardInfoResDto response = TeamDashboardInfoResDto.of(member, teamDashboard); + given(teamDashboardService.save(anyString(), any(TeamDashboardSaveReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/dashboards/team/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(teamDashboardSaveReqDto))) + .andDo(print()) + .andDo(document("dashboard/team/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("title").description("팀 대시보드 제목"), + fieldWithPath("description").description("팀 대시보드 설명"), + fieldWithPath("invitedEmails").description("초대할 멤버 이메일") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("팀 대시보드 제목"), + fieldWithPath("data.description").description("팀 대시보드 설명"), + fieldWithPath("data.blockProgress").description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.joinMembers").description("팀 대시보드에 참여한 사용자") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 팀 대시보드 수정 컨트롤러 로직 확인") + @Test + void 팀_대시보드_수정() throws Exception { + // given + teamDashboard.update(teamDashboardUpdateReqDto.title(), teamDashboardUpdateReqDto.description()); + TeamDashboardInfoResDto response = TeamDashboardInfoResDto.of(member, teamDashboard); + given(teamDashboardService.update(anyString(), anyLong(), any(TeamDashboardUpdateReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform(patch("/api/dashboards/team/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(teamDashboardUpdateReqDto))) + .andDo(print()) + .andDo(document("dashboard/team/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 ID") + ), + requestFields( + fieldWithPath("title").description("개인 대시보드 제목"), + fieldWithPath("description").description("개인 대시보드 설명"), + fieldWithPath("invitedEmails").description("초대할 멤버 이메일") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("팀 대시보드 제목"), + fieldWithPath("data.description").description("팀 대시보드 설명"), + fieldWithPath("data.blockProgress").description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.joinMembers").description("팀 대시보드에 참여한 사용자") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀 대시보드를 전체 조회합니다.") + @Test + void 팀_대시보드_전체_조회() throws Exception { + // given + TeamDashboardListResDto response = TeamDashboardListResDto.from( + Collections.singletonList(TeamDashboardInfoResDto.of(member, teamDashboard)) + ); + + given(teamDashboardService.findForTeamDashboard(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/findForTeamDashboard", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.teamDashboardInfoResDto[].dashboardId") + .description("대시보드 아이디"), + fieldWithPath("data.teamDashboardInfoResDto[].myId") + .description("내 아이디"), + fieldWithPath("data.teamDashboardInfoResDto[].creatorId") + .description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.teamDashboardInfoResDto[].title") + .description("팀 대시보드 제목"), + fieldWithPath("data.teamDashboardInfoResDto[].description") + .description("팀 대시보드 설명"), + fieldWithPath("data.teamDashboardInfoResDto[].blockProgress") + .description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.teamDashboardInfoResDto[].joinMembers") + .description("팀 대시보드에 참여한 사용자"), + fieldWithPath("data.pageInfoResDto") + .description("페이지 정보가 없습니다.") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀 대시보드를 상세봅니다.") + @Test + void 팀_대시보드_상세보기() throws Exception { + // given + TeamDashboardInfoResDto response = TeamDashboardInfoResDto.of(member, teamDashboard); + + given(teamDashboardService.findById(anyString(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 아이디") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("팀 대시보드 제목"), + fieldWithPath("data.description").description("팀 대시보드 설명"), + fieldWithPath("data.blockProgress").description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.joinMembers").description("팀 대시보드에 참여한 사용자") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 팀 대시보드를 논리적으로 삭제하고 복구합니다.") + @Test + void 팀_대시보드_삭제() throws Exception { + // given + doNothing().when(teamDashboardService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/dashboards/team/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("팀 대시보드 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("POST 팀 대시보드 초대를 수락합니다.") + @Test + void 팀_대시보드_초대_수락() throws Exception { + // given + doNothing().when(teamDashboardService).joinTeam(anyString(), anyLong()); + + // when & then + mockMvc.perform(post("/api/dashboards/team/{dashboardId}/join", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/join", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("팀 대시보드 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("POST 참여한 팀 대시보드를 탈퇴합니다.") + @Test + void 팀_대시보드_탈퇴() throws Exception { + // given + doNothing().when(teamDashboardService).leaveTeam(anyString(), anyLong()); + + // when & then + mockMvc.perform(post("/api/dashboards/team/{dashboardId}/leave", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/leave", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("팀 대시보드 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀원 초대 리스트를 조회합니다.") + @Test + void 팀_초대_멤버_조회() throws Exception { + // given + SearchMemberListResDto response = SearchMemberListResDto.from(List.of(joinMember)); + + given(teamDashboardService.searchMembers(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get(String.format("/api/dashboards/team/search?query=%s", "joinEmail")) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/search", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + queryParameters( + parameterWithName("query").description("검색 조건(이메일, 닉네임#고유번호)") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.searchMembers[].id").description("대시보드 아이디"), + fieldWithPath("data.searchMembers[].picture").description("내 아이디"), + fieldWithPath("data.searchMembers[].email").description("팀 대시보드 생성자 아이디") + ) + )) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardServiceTest.java new file mode 100644 index 00000000..9cc6716b --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardServiceTest.java @@ -0,0 +1,280 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@ExtendWith(MockitoExtension.class) +class TeamDashboardServiceTest { + + @Mock + private TeamDashboardRepository teamDashboardRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private TeamDashboardService teamDashboardService; + + private Member member; + private TeamDashboard teamDashboard; + private TeamDashboard deleteTeamDashboard; + private TeamDashboardSaveReqDto teamDashboardSaveReqDto; + private TeamDashboardUpdateReqDto teamDashboardUpdateReqDto; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + lenient().when(memberRepository.findByEmail(anyString())).thenReturn(Optional.ofNullable(member)); + + teamDashboardSaveReqDto = new TeamDashboardSaveReqDto("title", "description", List.of("joinEmail")); + teamDashboardUpdateReqDto = new TeamDashboardUpdateReqDto("updateTitle", "updateDescription", + List.of("joinEmail")); + + teamDashboard = TeamDashboard.builder() + .title(teamDashboardSaveReqDto.title()) + .description(teamDashboardSaveReqDto.description()) + .member(member) + .build(); + + deleteTeamDashboard = TeamDashboard.builder() + .title(teamDashboardSaveReqDto.title()) + .description(teamDashboardSaveReqDto.description()) + .member(member) + .build(); + deleteTeamDashboard.statusUpdate(); + } + + @DisplayName("팀 대시보드를 저장합니다.") + @Test + void 팀_대시보드_저장() { + // given + when(teamDashboardRepository.save(any(TeamDashboard.class))).thenReturn(teamDashboard); + + // when + TeamDashboardInfoResDto result = teamDashboardService.save(member.getEmail(), teamDashboardSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + }); + } + + @DisplayName("팀 대시보드를 수정합니다.") + @Test + void 팀_대시보드_수정() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + TeamDashboardInfoResDto result = teamDashboardService.update( + member.getEmail(), + dashboardId, + teamDashboardUpdateReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("updateTitle"); + assertThat(result.description()).isEqualTo("updateDescription"); + }); + } + + @DisplayName("생성자가 아닌 사용자가 팀 대시보드를 수정하는데 실패합니다. (DashboardAccessDeniedException 발생)") + @Test + void 팀_대시보드_수정_실패() { + // given + Long dashboardId = 1L; + String unauthorizedEmail = "unauthorizedEmail@email.com"; + + Member unauthorizedMember = Member.builder() + .email(unauthorizedEmail) + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(unauthorizedEmail)).thenReturn(Optional.of(unauthorizedMember)); + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when & then + assertThatThrownBy( + () -> teamDashboardService.update(unauthorizedEmail, dashboardId, teamDashboardUpdateReqDto) + ).isInstanceOf(DashboardAccessDeniedException.class); + } + + @DisplayName("팀 대시보드를 삭제합니다.") + @Test + void 팀_대시보드_삭제() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + teamDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(teamDashboard.getStatus()).isEqualTo(Status.DELETED); + }); + } + + @DisplayName("팀 대시보드를 전체 조회합니다.") + @Test + void 팀_대시보드_전체_조회() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page teamDashboardPage = new PageImpl<>( + List.of(teamDashboard), + pageable, + 1); + + when(teamDashboardRepository.findForTeamDashboard(any(Member.class), any(Pageable.class))) + .thenReturn(teamDashboardPage); + + // when + TeamDashboardListResDto result = teamDashboardService. + findForTeamDashboard(member.getEmail(), pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.teamDashboardInfoResDto()).hasSize(1); + } + + @DisplayName("팀 대시보드를 상세 봅니다.") + @Test + void 팀_대시보드_상세보기() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + TeamDashboardInfoResDto result = teamDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.blockProgress()).isEqualTo(0.0); + }); + } + + @DisplayName("삭제되었던 팀 대시보드를 복구합니다.") + @Test + void 팀_대시보드_복구() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(deleteTeamDashboard)); + + // when + teamDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(deleteTeamDashboard.getStatus()).isEqualTo(Status.ACTIVE); + }); + } + + @DisplayName("팀 대시보드에 참여합니다") + @Test + void 팀_대시보드_참여() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + teamDashboardService.joinTeam(member.getEmail(), dashboardId); + TeamDashboardInfoResDto result = teamDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.blockProgress()).isEqualTo(0.0); + assertThat(result.joinMembers().size()).isEqualTo(1); + }); + } + + @DisplayName("팀 대시보드를 탈퇴합니다.") + @Test + void 팀_대시보드_탈퇴() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + teamDashboardService.joinTeam(member.getEmail(), dashboardId); + teamDashboardService.leaveTeam(member.getEmail(), dashboardId); + TeamDashboardInfoResDto result = teamDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.blockProgress()).isEqualTo(0.0); + assertThat(result.joinMembers().size()).isEqualTo(0); + }); + } + + @DisplayName("팀원 초대 리스트를 조회합니다.") + @Test + void 팀_초대_멤버_조회() { + // given + String query = "email"; + when(teamDashboardRepository.findForMembersByQuery(query)).thenReturn(List.of(member)); + + // when + SearchMemberListResDto result = teamDashboardService.searchMembers(query); + + // then + assertAll(() -> { + assertThat(result.searchMembers().size()).isEqualTo(1); + }); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardTest.java new file mode 100644 index 00000000..ef976b8e --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardTest.java @@ -0,0 +1,98 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class TeamDashboardTest { + + private Member member; + private TeamDashboard teamDashboard; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + teamDashboard = TeamDashboard.builder() + .title("title") + .description("description") + .build(); + } + + @DisplayName("팀 대시보드의 모든 값을 수정합니다.") + @Test + void 팀_대시보드_수정() { + // given + String updateTitle = "updateTitle"; + String updateDescription = "updateDescription"; + + // when + teamDashboard.update(updateTitle, updateDescription); + + // then + assertAll(() -> { + assertThat(teamDashboard.getTitle()).isEqualTo(updateTitle); + assertThat(teamDashboard.getDescription()).isEqualTo(updateDescription); + }); + } + + @DisplayName("팀 대시보드 논리 삭제 상태를 수정합니다.") + @Test + void 팀_대시보드_상태_수정() { + // given + assertThat(teamDashboard.getStatus()).isEqualTo(Status.ACTIVE); + + // when + teamDashboard.statusUpdate(); + + // then + assertThat(teamDashboard.getStatus()).isEqualTo(Status.DELETED); + + // when + teamDashboard.statusUpdate(); + + // then + assertThat(teamDashboard.getStatus()).isEqualTo(Status.ACTIVE); + } + + @DisplayName("팀 대시보드에 참가합니다.") + @Test + void 팀_대시보드_참가() { + // given + assertTrue(teamDashboard.getTeamDashboardMemberMappings().isEmpty()); + + // when + teamDashboard.addMember(member); + + // then + assertThat(teamDashboard.getTeamDashboardMemberMappings().size()).isEqualTo(1); + } + + @DisplayName("팀 대시보드 탈퇴") + @Test + void 팀_대시보드_탈퇴() { + // given + teamDashboard.addMember(member); + assertThat(teamDashboard.getTeamDashboardMemberMappings().size()).isEqualTo(1); + + // when + teamDashboard.removeMember(member); + + // then + assertThat(teamDashboard.getTeamDashboardMemberMappings().size()).isEqualTo(0); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepositoryTest.java new file mode 100644 index 00000000..ab909bcd --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepositoryTest.java @@ -0,0 +1,141 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class TeamDashboardRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BlockRepository blockRepository; + + @Autowired + private TeamDashboardRepository teamDashboardRepository; + + private Member member; + private TeamDashboard teamDashboard; + private Block block1; + private Block block2; + private Block block3; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .tag("#0000") + .build(); + + memberRepository.save(member); + + teamDashboard = TeamDashboard.builder() + .member(member) + .title("title") + .description("description") + .build(); + + teamDashboardRepository.save(teamDashboard); + + block1 = Block.builder() + .title("title1") + .contents("contents1") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:03") + .dashboard(teamDashboard) + .build(); + + block2 = Block.builder() + .title("title2") + .contents("contents2") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:04") + .dashboard(teamDashboard) + .build(); + + block3 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:05") + .dashboard(teamDashboard) + .build(); + + blockRepository.save(block1); + blockRepository.save(block2); + blockRepository.save(block3); + } + + @DisplayName("팀 대시보드를 전체 조회합니다.") + @Test + void 팀_대시보드_전체_조회() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = teamDashboardRepository.findForTeamDashboard(member, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("title"); + assertThat(result.getContent().get(0).getMember()).isEqualTo(member); + } + + @DisplayName("팀 대시보드를 생성할 때 초대 멤버 리스트를 조회합니다.") + @Test + void 초대_멤버_조회() { + // given + String query1 = "email"; + String query2 = "nickname#0000"; + + // when + List result1 = teamDashboardRepository.findForMembersByQuery(query1); + List result2 = teamDashboardRepository.findForMembersByQuery(query2); + + // then + assertThat(result1.size()).isEqualTo(1); + assertThat(result1.get(0).getName()).isEqualTo("name"); + + assertThat(result2.size()).isEqualTo(1); + } + + @DisplayName("대시보드의 완료된 블록 비율을 계산합니다.") + @Test + void 팀_대시보드_완료_블록_비율_계산() { + // when + double completionPercentage = teamDashboardRepository + .calculateCompletionPercentage(teamDashboard.getId()); + + // then + assertThat(completionPercentage).isEqualTo(66.67, offset(0.01)); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/TeamDocumentControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/TeamDocumentControllerTest.java new file mode 100644 index 00000000..3b55a872 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdoc/api/TeamDocumentControllerTest.java @@ -0,0 +1,306 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.FindTeamDocumentReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.TeamDocumentReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.request.TeamDocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.FindTeamDocumentResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentDetailResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.api.dto.response.TeamDocumentResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdoc.application.TeamDocumentService; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +import java.util.Collections; +import java.util.List; + +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; + +import org.springframework.test.web.servlet.MockMvc; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +class TeamDocumentControllerTest extends ControllerTest { + + @InjectMocks + private TeamDocumentController teamDocumentController; + + @MockBean + private TeamDocumentService teamDocumentService; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + ReflectionTestUtils.setField(teamDocumentController, "teamDocumentService", teamDocumentService); + + mockMvc = MockMvcBuilders.standaloneSetup(teamDocumentController) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .apply(documentationConfiguration(restDocumentation)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + } + +// @DisplayName("POST 팀 문서 저장 테스트") +// @Test +// void save() throws Exception { +// // given +// TeamDocumentReqDto request = new TeamDocumentReqDto("title", "content", "category", 1L); +// TeamDocumentResDto response = new TeamDocumentResDto( +// "author", +// "https://k.kakaocdn.net/dn/bJY1vO/btsJm21aVow/IW9XqFaAdMXFDPvmtQPcK/img_110x110.jpg", +// "title", +// "category", +// 9L +// ); +// +// // Mocking TeamDocumentService +// given(teamDocumentService.save(anyString(), any(TeamDocumentReqDto.class))) +// .willReturn(response); +// +// // when & then +// mockMvc.perform(post("/api/dashboards/team/document") +// .header("Authorization", "Bearer valid-token") +// .contentType(MediaType.APPLICATION_JSON) +// .content(objectMapper.writeValueAsString(request))) +// .andDo(print()) +// .andExpect(status().isOk()) +// .andDo(document("teamDocument/save", +// preprocessRequest(prettyPrint()), +// preprocessResponse(prettyPrint()), +// requestHeaders( +// headerWithName("Authorization").description("JWT 토큰") +// ), +// requestFields( +// fieldWithPath("title").description("문서 제목"), +// fieldWithPath("content").description("문서 내용"), +// fieldWithPath("category").description("문서 카테고리"), +// fieldWithPath("teamDashboardId").description("팀 대시보드 ID") +// ), +// responseFields( +// fieldWithPath("statusCode").description("상태 코드"), +// fieldWithPath("message").description("응답 메시지"), +// fieldWithPath("data.author").description("문서 글쓴이"), +// fieldWithPath("data.picture").description("문서 글쓴이 사진 URL"), +// fieldWithPath("data.title").description("문서 제목"), +// fieldWithPath("data.category").description("문서 카테고리"), +// fieldWithPath("data.teamDocumentId").description("문서 ID") +// ) +// )); +// } + + @DisplayName("PATCH 팀 문서 수정 테스트") + @Test + void update() throws Exception { + // given + TeamDocumentUpdateReqDto request = new TeamDocumentUpdateReqDto("11111111", "111111", "호우"); + TeamDocumentResDto response = new TeamDocumentResDto("최인호", "picture", "11111111", "호우", 3L); + + given(teamDocumentService.update(any(), anyLong(), any(TeamDocumentUpdateReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform(patch("/api/dashboards/team/document/{teamDocumentId}", 3L) + .header("Authorization", "Bearer valid-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("teamDocument/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("teamDocumentId").description("팀 문서 ID") + ), + requestFields( + fieldWithPath("title").description("문서 제목"), + fieldWithPath("content").description("문서 내용"), + fieldWithPath("category").description("문서 카테고리") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.author").description("문서 작성자"), + fieldWithPath("data.picture").description("문서 작성자의 프로필 사진 URL"), + fieldWithPath("data.title").description("문서 제목"), + fieldWithPath("data.category").description("문서 카테고리"), + fieldWithPath("data.teamDocumentId").description("문서 ID") + ) + )); + } + + @DisplayName("GET 팀 문서 상세 조회 테스트") + @Test + void findById() throws Exception { + // given + Long teamDocumentId = 1L; + TeamDocumentDetailResDto response = new TeamDocumentDetailResDto("author", "picture", "title", "content", "category", teamDocumentId); + + given(teamDocumentService.findById(teamDocumentId)).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/document/{teamDocumentId}", teamDocumentId) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andDo(document("teamDocument/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("teamDocumentId").description("팀 문서 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.author").description("문서 작성자"), + fieldWithPath("data.picture").description("문서 작성자의 프로필 사진"), + fieldWithPath("data.title").description("문서 제목"), + fieldWithPath("data.content").description("문서 내용"), + fieldWithPath("data.category").description("문서 카테고리"), + fieldWithPath("data.teamDocumentId").description("문서 ID") + ) + )); + } + + @DisplayName("GET 팀 문서 카테고리 조회 테스트") + @Test + void findCategories() throws Exception { + // given + Long teamDashboardId = 1L; + TeamDocumentCategoriesResDto response = new TeamDocumentCategoriesResDto(List.of("category1", "category2")); + + given(teamDocumentService.findTeamDocumentCategory(teamDashboardId)).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/document/categories/{teamDashboardId}", teamDashboardId) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andDo(document("teamDocument/findCategories", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("teamDashboardId").description("팀 대시보드 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.categories[]").description("문서 카테고리 리스트") + ) + )); + } + + @DisplayName("GET 팀 문서 카테고리 조회 테스트") + @Test + void findTeamDocumentByCategory() throws Exception { + // given + List teamDocuments = List.of( + new TeamDocumentResDto("최인호", "picture", "", "이능", 6L), + new TeamDocumentResDto("최인호", "picture", "", "이능", 7L) + ); + + PageInfoResDto pageInfoResDto = new PageInfoResDto(0, 1, 2); + + FindTeamDocumentResDto response = FindTeamDocumentResDto.from(teamDocuments, pageInfoResDto); + + given(teamDocumentService.findTeamDocumentByCategory(anyLong(), anyString(), any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/document/search/{teamDashboardId}", 3L) + .param("category", "이능") + .param("page", "0") + .param("size", "10") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("teamDocument/search-by-category", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("teamDashboardId").description("팀 대시보드 ID") + ), + queryParameters( + parameterWithName("category").description("문서 카테고리"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.teamDocuments[]").description("문서 리스트"), + fieldWithPath("data.teamDocuments[].author").description("문서 작성자"), + fieldWithPath("data.teamDocuments[].picture").description("문서 작성자의 프로필 사진 URL"), + fieldWithPath("data.teamDocuments[].title").description("문서 제목"), + fieldWithPath("data.teamDocuments[].category").description("문서 카테고리"), + fieldWithPath("data.teamDocuments[].teamDocumentId").description("문서 ID"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지 수"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템 수") + ) + )); + } + + @DisplayName("DELETE 팀 문서 삭제 테스트") + @Test + void deleteTeamDocument() throws Exception { + // given + doNothing().when(teamDocumentService).delete(anyLong()); + + // when & then + mockMvc.perform(delete("/api/dashboards/team/document/{teamDocumentId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("teamDocument/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("teamDocumentId").description("팀 문서 ID") + ) + )); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentControllerTest.java new file mode 100644 index 00000000..73ddce11 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentControllerTest.java @@ -0,0 +1,212 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; + +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; + +import java.util.Collections; +import java.util.List; + +class DocumentControllerTest extends ControllerTest { + + @InjectMocks + DocumentController documentController; + + private Document document; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + documentController = new DocumentController(documentService); + + mockMvc = MockMvcBuilders.standaloneSetup(documentController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + document = Document.builder() + .title("DocumentTitle") + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + } + + @DisplayName("POST 팀 문서 저장 컨트롤러 로직 확인") + @Test + void POST_팀_문서_저장_컨트롤러_로직_확인() throws Exception { + // given + DocumentInfoReqDto request = new DocumentInfoReqDto(1L, "DocumentTitle"); + DocumentInfoResDto response = new DocumentInfoResDto(1L, "DocumentTitle"); + + given(documentService.save(any(DocumentInfoReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/documents") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("document/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("teamDashboardId").description("팀 대시보드 ID"), + fieldWithPath("title").description("문서 제목") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.documentId").description("문서 ID"), + fieldWithPath("data.title").description("문서 제목") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 팀 문서 수정 컨트롤러 로직 확인") + @Test + void PATCH_팀_문서_수정_컨트롤러_로직_확인() throws Exception { + // given + Long documentId = 1L; + DocumentUpdateReqDto request = new DocumentUpdateReqDto("Updated Document Title"); + DocumentInfoResDto response = new DocumentInfoResDto(documentId, "Updated Document Title"); + + given(documentService.update(anyLong(), any(DocumentUpdateReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/documents/{documentId}", documentId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("document/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("documentId").description("문서 ID") + ), + requestFields( + fieldWithPath("title").description("문서 제목") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.documentId").description("문서 ID"), + fieldWithPath("data.title").description("문서 제목") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀 문서 리스트 조회 컨트롤러 로직 확인") + @Test + void GET_팀_문서_리스트_조회_컨트롤러_로직_확인() throws Exception { + // given + PageRequest pageRequest = PageRequest.of(0, 10); + Page documentPage = new PageImpl<>(List.of(document)); + + DocumentListResDto response = new DocumentListResDto( + Collections.singletonList(new DocumentInfoResDto(1L, "DocumentTitle")), + PageInfoResDto.from(documentPage) + ); + + given(documentService.findDocumentByTeamDashboardId(anyLong(), any(PageRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/documents") + .param("teamDashboardId", "1") + .param("page", "0") + .param("size", "10") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("document/findForDocumentByTeamDashboardId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("teamDashboardId").description("팀 대시보드 ID"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.documentInfoResDtos[].documentId").description("문서 ID"), + fieldWithPath("data.documentInfoResDtos[].title").description("문서 제목"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지 수"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템 수") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 팀 문서 삭제 컨트롤러 로직 확인") + @Test + void DELETE_팀_문서_삭제_컨트롤러_로직_확인() throws Exception { + // given + Long documentId = 1L; + doNothing().when(documentService).delete(anyLong()); + + // when & then + mockMvc.perform(delete("/api/documents/{documentId}", documentId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("document/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("documentId").description("문서 ID") + ) + )) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileControllerTest.java new file mode 100644 index 00000000..067fb75c --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileControllerTest.java @@ -0,0 +1,268 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.FileService; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; + +class FileControllerTest extends ControllerTest{ + + @InjectMocks + FileController fileController; + private Document document; + private File file; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + fileController = new FileController(fileService); + + mockMvc = MockMvcBuilders.standaloneSetup(fileController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + document = Document.builder() + .title("DocumentTitle") + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + + file = File.builder() + .email("email") + .title("FileTitle") + .content("FileContent") + .document(document) + .build(); + + when(tokenProvider.getUserEmailFromToken(any())).thenReturn("email"); + } + + @DisplayName("POST 파일 저장 컨트롤러 로직 확인") + @Test + void POST_파일_저장_컨트롤러_로직_확인() throws Exception { + // given + FileInfoReqDto request = new FileInfoReqDto(1L, "email", "title", "content"); + FileInfoResDto response = new FileInfoResDto(1L, "title", "content", "email"); + + given(fileService.save(anyString(), any(FileInfoReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/files/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("file/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("documentId").description("문서 ID"), + fieldWithPath("email").description("파일 생성자 이메일"), + fieldWithPath("title").description("파일 제목"), + fieldWithPath("content").description("파일 내용") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileId").description("파일 ID"), + fieldWithPath("data.title").description("파일 제목"), + fieldWithPath("data.content").description("파일 내용"), + fieldWithPath("data.email").description("파일 생성자 이메일") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 파일 수정 컨트롤러 로직 확인") + @Test + void PATCH_파일_수정_컨트롤러_로직_확인() throws Exception { + // given + Long fileId = 1L; + FileInfoReqDto request = new FileInfoReqDto(1L, "email", "updatedTitle", "updatedContent"); + FileInfoResDto response = new FileInfoResDto(fileId, "updatedTitle", "updatedContent", "email"); + + given(fileService.update(anyLong(), any(FileInfoReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/files/{fileId}", fileId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("file/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("fileId").description("파일 ID") + ), + requestFields( + fieldWithPath("documentId").description("문서 ID"), + fieldWithPath("email").description("파일 생성자 이메일"), + fieldWithPath("title").description("파일 제목"), + fieldWithPath("content").description("파일 내용") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileId").description("파일 ID"), + fieldWithPath("data.title").description("파일 제목"), + fieldWithPath("data.content").description("파일 내용"), + fieldWithPath("data.email").description("파일 생성자 이메일") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 파일 리스트 조회 컨트롤러 로직 확인") + @Test + void GET_파일_리스트_조회_컨트롤러_로직_확인() throws Exception { + // given + PageRequest pageRequest = PageRequest.of(0, 10); + Page filePage = new PageImpl<>(List.of(file)); + + FileListResDto response = new FileListResDto( + Collections.singletonList(new FileInfoResDto(1L, "title", "content", "email")), + PageInfoResDto.from(filePage) + ); + + given(fileService.findForFile(anyLong(), any(PageRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/files") + .param("documentId", "1") + .param("page", "0") + .param("size", "10") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("file/findForFile", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("documentId").description("문서 ID"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileInfoResDto[].fileId").description("파일 ID"), + fieldWithPath("data.fileInfoResDto[].title").description("파일 제목"), + fieldWithPath("data.fileInfoResDto[].content").description("파일 내용"), + fieldWithPath("data.fileInfoResDto[].email").description("파일 생성자 이메일"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지 수"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템 수") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 파일 상세보기 컨트롤러 로직 확인") + @Test + void GET_파일_상세보기_컨트롤러_로직_확인() throws Exception { + // given + Long fileId = 1L; + FileInfoResDto response = new FileInfoResDto(fileId, "title", "content", "email"); + + given(fileService.findById(anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/files/{fileId}", fileId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("file/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("fileId").description("파일 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileId").description("파일 ID"), + fieldWithPath("data.title").description("파일 제목"), + fieldWithPath("data.content").description("파일 내용"), + fieldWithPath("data.email").description("파일 생성자 이메일") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 파일 삭제 컨트롤러 로직 확인") + @Test + void DELETE_파일_삭제_컨트롤러_로직_확인() throws Exception { + // given + Long fileId = 1L; + doNothing().when(fileService).delete(anyLong()); + + // when & then + mockMvc.perform(delete("/api/files/{fileId}", fileId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("file/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("fileId").description("파일 ID") + ) + )) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentServiceTest.java new file mode 100644 index 00000000..9b5f78a4 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentServiceTest.java @@ -0,0 +1,180 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.TeamDashboardNotFoundException; + +import java.util.List; +import java.util.Optional; + +class DocumentServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private TeamDashboardRepository teamDashboardRepository; + + @InjectMocks + private DocumentService documentService; + + private Document document; + private TeamDashboard teamDashboard; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + teamDashboard = TeamDashboard.builder() + .title("Team Dashboard Title") + .description("Team Dashboard Description") + .build(); + + ReflectionTestUtils.setField(teamDashboard, "id", 1L); + + document = Document.builder() + .title("Document Title") + .teamDashboard(teamDashboard) + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + } + + @DisplayName("문서를 생성합니다.") + @Test + void 문서를_생성합니다() { + // given + DocumentInfoReqDto documentInfoReqDto = new DocumentInfoReqDto(1L, "Document Title"); + + when(teamDashboardRepository.findById(anyLong())).thenReturn(Optional.of(teamDashboard)); + when(documentRepository.save(any(Document.class))).thenReturn(document); + + // when + DocumentInfoResDto result = documentService.save(documentInfoReqDto); + + // then + assertThat(result.title()).isEqualTo(document.getTitle()); + + verify(teamDashboardRepository).findById(documentInfoReqDto.teamDashboardId()); + verify(documentRepository).save(any(Document.class)); + } + + @DisplayName("존재하지 않는 팀 대시보드에 문서를 생성 시 예외가 발생합니다.") + @Test + void 존재하지_않는_팀_대시보드에_문서를_생성_시_예외가_발생합니다() { + // given + DocumentInfoReqDto documentInfoReqDto = new DocumentInfoReqDto(999L, "Document Title"); + + when(teamDashboardRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> documentService.save(documentInfoReqDto)) + .isInstanceOf(TeamDashboardNotFoundException.class); + + verify(teamDashboardRepository).findById(documentInfoReqDto.teamDashboardId()); + verify(documentRepository, never()).save(any(Document.class)); + } + + @DisplayName("문서를 성공적으로 수정합니다.") + @Test + void 문서를_성공적으로_수정합니다() { + // given + DocumentUpdateReqDto documentUpdateReqDto = new DocumentUpdateReqDto("Updated Document Title"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.of(document)); + + // when + DocumentInfoResDto result = documentService.update(1L, documentUpdateReqDto); + + // then + assertThat(result.title()).isEqualTo(documentUpdateReqDto.title()); + + verify(documentRepository).findById(1L); + } + + @DisplayName("존재하지 않는 문서를 수정 시 예외가 발생합니다.") + @Test + void 존재하지_않는_문서를_수정_시_예외가_발생합니다() { + // given + DocumentUpdateReqDto documentUpdateReqDto = new DocumentUpdateReqDto("Updated Document Title"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> documentService.update(1L, documentUpdateReqDto)) + .isInstanceOf(DocumentNotFoundException.class); + + verify(documentRepository).findById(1L); + } + + @DisplayName("팀 대시보드 ID로 문서를 성공적으로 조회합니다.") + @Test + void 팀_대시보드_ID로_문서를_성공적으로_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page documents = new PageImpl<>(List.of(document)); + + when(documentRepository.findByDocumentWithTeamDashboard(anyLong(), any(Pageable.class))).thenReturn(documents); + + // when + DocumentListResDto result = documentService.findDocumentByTeamDashboardId(1L, pageable); + + // then + assertThat(result.documentInfoResDtos().size()).isEqualTo(1); + assertThat(result.documentInfoResDtos().get(0).title()).isEqualTo(document.getTitle()); + + verify(documentRepository).findByDocumentWithTeamDashboard(1L, pageable); + } + + @DisplayName("문서를 논리적으로 삭제합니다.") + @Test + void 문서를_논리적으로_삭제합니다() { + // given + when(documentRepository.findById(anyLong())).thenReturn(Optional.of(document)); + + // when + documentService.delete(1L); + + // then + assertThat(document.getStatus()).isEqualTo(shop.kkeujeok.kkeujeokbackend.global.entity.Status.DELETED); + + verify(documentRepository).findById(1L); + } + + @DisplayName("존재하지 않는 문서를 삭제 시 예외가 발생합니다.") + @Test + void 존재하지_않는_문서를_삭제_시_예외가_발생합니다() { + // given + when(documentRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> documentService.delete(1L)) + .isInstanceOf(DocumentNotFoundException.class); + + verify(documentRepository).findById(1L); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileServiceTest.java new file mode 100644 index 00000000..933af8dd --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileServiceTest.java @@ -0,0 +1,210 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.FileRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.FileNotFoundException; + +import java.util.List; +import java.util.Optional; + +class FileServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private FileRepository fileRepository; + + @InjectMocks + private FileService fileService; + + private File file; + + private Document document; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + document = Document.builder() + .title("DocumentTitle") + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + + file = File.builder() + .email("email") + .title("FileTitle") + .content("FileContent") + .document(document) + .build(); + } + + @DisplayName("파일을 생성합니다.") + @Test + void 파일을_생성합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(document.getId(), "email", "FileTitle", "FileContent"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.of(document)); + when(fileRepository.save(any(File.class))).thenReturn(file); + + // when + FileInfoResDto result = fileService.save("New email", fileInfoReqDto); + + // then + assertThat(result.title()).isEqualTo(file.getTitle()); + assertThat(result.content()).isEqualTo(file.getContent()); + + verify(documentRepository).findById(fileInfoReqDto.documentId()); + verify(fileRepository).save(any(File.class)); + } + + @DisplayName("존재하지 않는 문서에 파일 생성 시 예외가 발생합니다.") + @Test + void 존재하지_않는_문서에_파일_생성_시_예외가_발생합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(999L, "email", "New File", "New Content"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.save("noExistEmail", fileInfoReqDto)) + .isInstanceOf(DocumentNotFoundException.class); + + verify(documentRepository).findById(fileInfoReqDto.documentId()); + verify(fileRepository, never()).save(any(File.class)); + } + + @DisplayName("파일을 성공적으로 수정합니다.") + @Test + void 파일을_성공적으로_수정합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(document.getId(), "email", "Updated Title", "Updated Content"); + + when(fileRepository.findById(anyLong())).thenReturn(Optional.of(file)); + + // when + FileInfoResDto result = fileService.update(1L, fileInfoReqDto); + + // then + assertThat(result.title()).isEqualTo(fileInfoReqDto.title()); + assertThat(result.content()).isEqualTo(fileInfoReqDto.content()); + + verify(fileRepository).findById(1L); + } + + @DisplayName("존재하지 않는 파일 수정 시 예외가 발생합니다.") + @Test + void 존재하지_않는_파일_수정_시_예외가_발생합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(document.getId(), "email", "Updated Title", "Updated Content"); + + when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.update(1L, fileInfoReqDto)) + .isInstanceOf(FileNotFoundException.class); + + verify(fileRepository).findById(1L); + } + + @DisplayName("파일 리스트를 성공적으로 조회합니다.") + @Test + void 파일_리스트를_성공적으로_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page files = new PageImpl<>(List.of(file)); + + when(fileRepository.findByFilesWithDocumentId(anyLong(), any(Pageable.class))).thenReturn(files); + + // when + FileListResDto result = fileService.findForFile(1L, pageable); + + // then + assertThat(result.fileInfoResDto().size()).isEqualTo(1); + assertThat(result.fileInfoResDto().get(0).title()).isEqualTo(file.getTitle()); + + verify(fileRepository).findByFilesWithDocumentId(1L, pageable); + } + + @DisplayName("파일을 성공적으로 조회합니다.") + @Test + void 파일을_성공적으로_조회합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.of(file)); + + // when + FileInfoResDto result = fileService.findById(1L); + + // then + assertThat(result.title()).isEqualTo(file.getTitle()); + assertThat(result.content()).isEqualTo(file.getContent()); + + verify(fileRepository).findById(1L); + } + + @DisplayName("존재하지 않는 파일 조회 시 예외가 발생합니다.") + @Test + void 존재하지_않는_파일_조회_시_예외가_발생합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.findById(1L)) + .isInstanceOf(FileNotFoundException.class); + + verify(fileRepository).findById(1L); + } + + @DisplayName("파일을 논리적으로 삭제합니다.") + @Test + void 파일을_논리적으로_삭제합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.of(file)); + + // when + fileService.delete(1L); + + // then + assertThat(file.getStatus()).isEqualTo(shop.kkeujeok.kkeujeokbackend.global.entity.Status.DELETED); + + verify(fileRepository).findById(1L); + } + + @DisplayName("존재하지 않는 파일 삭제 시 예외가 발생합니다.") + @Test + void 존재하지_않는_파일_삭제_시_예외가_발생합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.delete(1L)) + .isInstanceOf(FileNotFoundException.class); + + verify(fileRepository).findById(1L); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/DocumentTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/DocumentTest.java new file mode 100644 index 00000000..39f26f64 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/DocumentTest.java @@ -0,0 +1,60 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class DocumentTest { + + private TeamDashboard teamDashboard; + private Document document; + + @BeforeEach + void setUp() { + teamDashboard = TeamDashboard.builder() + .title("teamDashboardTitle") + .description("teamDashboardDescription") + .build(); + + document = Document.builder() + .title("documentTitle") + .teamDashboard(teamDashboard) + .build(); + } + + @DisplayName("문서의 모든 값을 수정합니다.") + @Test + void 문서의_모든_값을_수정합니다() { + // given + String updateTitle = "Updated Document Title"; + + // when + document.update(updateTitle); + + // then + assertThat(document.getTitle()).isEqualTo(updateTitle); + } + + @DisplayName("문서의 논리 삭제 상태를 수정합니다.") + @Test + void 문서의_논리_삭제_상태를_수정합니다() { + // given + assertThat(document.getStatus()).isEqualTo(Status.ACTIVE); + + // when + document.statusUpdate(); + + // then + assertThat(document.getStatus()).isEqualTo(Status.DELETED); + + // when + document.statusUpdate(); + + // then + assertThat(document.getStatus()).isEqualTo(Status.ACTIVE); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/FileTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/FileTest.java new file mode 100644 index 00000000..ab29b51b --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/FileTest.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class FileTest { + + private Document document; + private File file; + + @BeforeEach + void setUp() { + document = Document.builder() + .title("documentTitle") + .build(); + + file = File.builder() + .email("email") + .title("fileTitle") + .content("content") + .document(document) + .build(); + } + + @DisplayName("파일의 모든 값을 수정합니다.") + @Test + void 파일_수정() { + // given + String updateTitle = "Updated Title"; + String updateContent = "Updated Content"; + + // when + file.update(updateTitle, updateContent); + + // then + assertAll(() -> { + assertThat(file.getTitle()).isEqualTo(updateTitle); + assertThat(file.getContent()).isEqualTo(updateContent); + }); + } + + @DisplayName("파일의 논리 삭제 상태를 수정합니다.") + @Test + void 파일의_논리_삭제_상태를_수정합니다() { + // given + assertThat(file.getStatus()).isEqualTo(Status.ACTIVE); + + // when + file.statusUpdate(); + + // then + assertThat(file.getStatus()).isEqualTo(Status.DELETED); + + // when + file.statusUpdate(); + + // then + assertThat(file.getStatus()).isEqualTo(Status.ACTIVE); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepositoryTest.java new file mode 100644 index 00000000..cf984f20 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepositoryTest.java @@ -0,0 +1,92 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class DocumentRepositoryTest { + + @Autowired + private DocumentRepository documentRepository; + + @Autowired + private TeamDashboardRepository teamDashboardRepository; + + private TeamDashboard teamDashboard; + private Document document1; + private Document document2; + private Document document3; + + @BeforeEach + void setUp() { + teamDashboard = TeamDashboard.builder() + .title("title") + .description("description") + .build(); + + document1 = Document.builder() + .title("Document Title 1") + .teamDashboard(teamDashboard) + .build(); + + document2 = Document.builder() + .title("Document Title 2") + .teamDashboard(teamDashboard) + .build(); + + document3 = Document.builder() + .title("Document Title 3") + .teamDashboard(teamDashboard) + .build(); + + teamDashboardRepository.save(teamDashboard); + documentRepository.save(document1); + documentRepository.save(document2); + documentRepository.save(document3); + } + + @DisplayName("팀 대시보드 ID로 문서를 전체 조회합니다.") + @Test + void 팀_대시보드_ID로_문서를_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page documents = documentRepository.findByDocumentWithTeamDashboard(teamDashboard.getId(), pageable); + + // then + assertThat(documents.getContent().size()).isEqualTo(3); + assertThat(documents.getContent()).extracting("teamDashboard.Id").containsOnly(teamDashboard.getId()); + } + + @DisplayName("문서를 논리 삭제 상태별로 전체 조회합니다.") + @Test + void 문서를_논리_삭제_상태별로_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + document1.statusUpdate(); + + // when + Page documents = documentRepository.findByDocumentWithTeamDashboard(teamDashboard.getId(), pageable); + + // then + assertThat(documents.getContent().size()).isEqualTo(2); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepositoryTest.java new file mode 100644 index 00000000..ccfc535b --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepositoryTest.java @@ -0,0 +1,96 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class FileRepositoryTest { + + @Autowired + private FileRepository fileRepository; + + @Autowired + private DocumentRepository documentRepository; + + private Document document; + private File file1; + private File file2; + private File file3; + + @BeforeEach + void setUp() { + document = Document.builder() + .title("Document Title") + .build(); + + file1 = File.builder() + .email("email1") + .title("title1") + .content("content1") + .document(document) + .build(); + + file2 = File.builder() + .email("email2") + .title("title2") + .content("content2") + .document(document) + .build(); + + file3 = File.builder() + .email("email3") + .title("title3") + .content("content3") + .document(document) + .build(); + + documentRepository.save(document); + fileRepository.save(file1); + fileRepository.save(file2); + fileRepository.save(file3); + } + + @DisplayName("Document ID로 파일을 전체 조회합니다.") + @Test + void Document_ID로_파일을_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page files = fileRepository.findByFilesWithDocumentId(document.getId(), pageable); + + // then + assertThat(files.getContent().size()).isEqualTo(3); + assertThat(files.getContent()).extracting("document.id").containsOnly(document.getId()); + } + + @DisplayName("파일을 논리 삭제 상태별로 전체 조회합니다.") + @Test + void 파일을_논리_삭제_상태별로_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + file1.statusUpdate(); + + // when + Page files = fileRepository.findByFilesWithDocumentId(document.getId(), pageable); + + // then + assertThat(files.getContent().size()).isEqualTo(2); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolverTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolverTest.java new file mode 100644 index 00000000..2136f825 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolverTest.java @@ -0,0 +1,99 @@ +package shop.kkeujeok.kkeujeokbackend.global.annotationresolver; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class CurrentUserEmailArgumentResolverTest { + + @Mock + private TokenProvider tokenProvider; + + @Mock + private HttpServletRequest request; + + @Mock + private NativeWebRequest webRequest; + + @Mock + private MethodParameter methodParameter; + + @InjectMocks + private CurrentUserEmailArgumentResolver resolver; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + @DisplayName("어노테이션이 있을 때는 true를 반환하고, 없을 때는 false를 반환합니다.") + @Test + public void 어노테이션이_있을_때는_true를_반환하고_없을_때는_false를_반환합니다() { + when(methodParameter.getParameterAnnotation(CurrentUserEmail.class)).thenReturn(mock(CurrentUserEmail.class)); + assertTrue(resolver.supportsParameter(methodParameter)); + + when(methodParameter.getParameterAnnotation(CurrentUserEmail.class)).thenReturn(null); + assertFalse(resolver.supportsParameter(methodParameter)); + } + + @DisplayName("토큰을 통해 이메일을 추출합니다.") + @Test + public void 토큰을_통해_이메일을_추출합니다() { + String token = "Bearer someValidToken"; + String userEmail = "user@example.com"; + + when(webRequest.getNativeRequest()).thenReturn(request); + when(request.getHeader("Authorization")).thenReturn(token); + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn(userEmail); + + Object result = resolver.resolveArgument(methodParameter, + mock(ModelAndViewContainer.class), + webRequest, + mock(WebDataBinderFactory.class)); + + assertEquals(userEmail, result); + } + + @DisplayName("토큰이 없을 때 NULL을 호출합니다.") + @Test + public void 토큰이_없을_때_NULL을_호출합니다() { + when(webRequest.getNativeRequest()).thenReturn(request); + when(request.getHeader("Authorization")).thenReturn(null); + + Object result = resolver.resolveArgument(methodParameter, + mock(ModelAndViewContainer.class), + webRequest, + mock(WebDataBinderFactory.class)); + + assertNull(result); + } + + @DisplayName("토큰이 잘못됐을 때 NULL을 호출합니다.") + @Test + public void 토큰이_잘못됐을_때_NULL을_호출합니다() { + String token = "InvalidTokenFormat"; + + when(webRequest.getNativeRequest()).thenReturn(request); + when(request.getHeader("Authorization")).thenReturn(token); + + Object result = resolver.resolveArgument(methodParameter, + mock(ModelAndViewContainer.class), + webRequest, + mock(WebDataBinderFactory.class)); + + assertNull(result); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilterTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilterTest.java new file mode 100644 index 00000000..f494a4b6 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilterTest.java @@ -0,0 +1,92 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +import java.io.IOException; + +import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class LoginCheckFilterTest { + + @Mock(lenient = true) + private TokenProvider tokenProvider; // Mock으로 TokenProvider 주입 lenient = ture는 불필요한 스터빙에 대한 경고를 하지 않게 한다 + + @InjectMocks + private LoginCheckFilter loginCheckFilter; // InjectMocks로 필터 주입 + + @BeforeEach + public void setUp() { + loginCheckFilter = new LoginCheckFilter(tokenProvider); // 필터 인스턴스를 직접 초기화하여 Mock TokenProvider 주입 + } + + @DisplayName("access토큰으로 api 인가를 받을 수 있다.") + @Test + public void access토큰으로_api_인가를_받을_수_있다() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + request.setRequestURI("/inho"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain filterChain = new MockFilterChain(); + + when(tokenProvider.validateToken("valid-token")).thenReturn(true); + + loginCheckFilter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isNotEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + +// 화이트리스트를 전부 열어 두었기 때문에 해당 테스트는 실패 (개발 끝나면 열 예정) +// @DisplayName("access토큰 없이는 api 인가를 받을 수 없다.") +// @Test +// public void access토큰_없이는_api_인가를_받을_수_없다() throws IOException, ServletException { +// MockHttpServletRequest request = new MockHttpServletRequest(); +// request.setRequestURI("/inho"); +// MockHttpServletResponse response = new MockHttpServletResponse(); +// MockFilterChain filterChain = new MockFilterChain(); +// +// loginCheckFilter.doFilter(request, response, filterChain); +// +// assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); +// } + + @DisplayName("화이트 리스트를 열어 두면 필터가 생략된다.") + @Test + public void 화이트_리스트를_열어_두면_필터가_생략된다() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain filterChain = new MockFilterChain(); + + loginCheckFilter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isNotEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + +// 화이트리스트를 전부 열어 두었기 때문에 해당 테스트는 실패 (개발 끝나면 열 예정) +// @DisplayName("화이트 리스트를 닫아 두면 필터가 작동한다.") +// @Test +// public void 화이트_리스트를_닫아_두면_필터가_작동한다() throws IOException, ServletException { +// MockHttpServletRequest request = new MockHttpServletRequest(); +// request.setRequestURI("/inho"); +// MockHttpServletResponse response = new MockHttpServletResponse(); +// MockFilterChain filterChain = new MockFilterChain(); +// +// loginCheckFilter.doFilter(request, response, filterChain); +// +// assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); +// } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java index 763c26b1..c0d673e7 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java @@ -6,10 +6,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; import java.security.Key; import static org.assertj.core.api.Assertions.assertThat; +@ActiveProfiles("test") public class TokenProviderTest { private final String accessTokenExpireTime = "3600"; diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java index 9e32a868..f7769d5d 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java @@ -2,10 +2,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; import shop.kkeujeok.kkeujeokbackend.member.domain.Member; import static org.assertj.core.api.Assertions.assertThat; +@ActiveProfiles("test") public class TokenTest { @DisplayName("refresh token을 교체한다.") @Test diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java index 5dd80b20..a8369ceb 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java @@ -1,4 +1,62 @@ package shop.kkeujeok.kkeujeokbackend.global.oauth; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") public class GoogleAuthServiceTest { + + @Mock + private ObjectMapper objectMapper; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private GoogleAuthService googleAuthService; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + googleAuthService = new GoogleAuthService(objectMapper, restTemplate); + } + + // getIdToken 테스트코드.. + + @DisplayName("올바르게 구글 소셜 정보가 넘어가는지 확인합니다.") + @Test + void 올바르게_구글_소셜_정보가_넘어가는지_확인합니다() { + String provider = "google"; + String actualProvider = googleAuthService.getProvider(); + + assertEquals(provider, actualProvider); + } + + @DisplayName("JWT 토큰을 사용하여 유저 정보를 반환합니다.") + @Test + void JWT_토큰을_사용하여_유저_정보를_반환합니다() throws Exception { + String token = "test.test.test"; + + String decodePayload = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1]), StandardCharsets.UTF_8); + UserInfo userInfo = new UserInfo("email", "name", "picture", "nickname"); + + when(objectMapper.readValue(decodePayload, UserInfo.class)).thenReturn(userInfo); + + assertNotNull(googleAuthService.getUserInfo(token)); + } } diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java index dca0176b..12935210 100644 --- a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java @@ -1,4 +1,61 @@ package shop.kkeujeok.kkeujeokbackend.global.oauth; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") public class KakaoAuthServiceTest { + @Mock + private ObjectMapper objectMapper; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private KakaoAuthService kakaoAuthService; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + kakaoAuthService = new KakaoAuthService(objectMapper, restTemplate); + } + + // getIdToken 테스트코드.. + + @DisplayName("올바르게 카카오 소셜 정보가 넘어가는지 확인합니다.") + @Test + void 올바르게_카카오_소셜_정보가_넘어가는지_확인합니다() { + String provider = "kakao"; + String actualProvider = kakaoAuthService.getProvider(); + + assertEquals(provider, actualProvider); + } + + @DisplayName("JWT 토큰을 사용하여 유저 정보를 반환합니다.") + @Test + void JWT_토큰을_사용하여_유저_정보를_반환합니다() throws Exception { + String token = "test.test.test"; + + String decodePayload = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1]), StandardCharsets.UTF_8); + UserInfo userInfo = new UserInfo("email", "name", "picture","nickname"); + + when(objectMapper.readValue(decodePayload, UserInfo.class)).thenReturn(userInfo); + + assertNotNull(kakaoAuthService.getUserInfo(token)); + } } diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberControllerTest.java new file mode 100644 index 00000000..8d391b41 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberControllerTest.java @@ -0,0 +1,210 @@ +package shop.kkeujeok.kkeujeokbackend.member.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; + +import java.util.ArrayList; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.Matchers.is; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +public class MemberControllerTest extends ControllerTest { + + @InjectMocks + private MemberController memberController; + + private Member member; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + memberController = new MemberController(myPageService); + + mockMvc = MockMvcBuilders.standaloneSetup(memberController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider), + new PageableHandlerMethodArgumentResolver()) // 추가 + .build(); + + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + } + + @DisplayName("내 프로필 정보를 가져옵니다.") + @Test + void 내_프로필_정보를_가져옵니다() throws Exception { + MyPageInfoResDto myPageInfoResDto = new MyPageInfoResDto( + "picture", + "email", + "name", + "nickname", + SocialType.GOOGLE, + "introduction"); + + when(myPageService.findMyProfileByEmail(anyString())).thenReturn(myPageInfoResDto); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + + mockMvc.perform(get("/api/members/mypage") + .header("Authorization", "Bearer valid-token")) + .andDo(print()) + .andDo(document("member/mypage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.picture").description("회원 사진"), + fieldWithPath("data.email").description("회원 이메일"), + fieldWithPath("data.name").description("회원 이름"), + fieldWithPath("data.nickName").description("회원 닉네임"), + fieldWithPath("data.socialType").description("회원 소셜 타입"), + fieldWithPath("data.introduction").description("회원 소개") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("내 프로필 정보"))) + .andExpect(jsonPath("$.data").exists()); + } + + @DisplayName("팀 대시보드와 챌린지 정보를 가져옵니다.") + @Test + void 팀_대시보드와_챌린지_정보를_가져옵니다() throws Exception { + TeamDashboardsAndChallengesResDto resDto = new TeamDashboardsAndChallengesResDto( + new TeamDashboardListResDto(new ArrayList<>(), new PageInfoResDto(0, 0, 0)), + new ChallengeListResDto(new ArrayList<>(), new PageInfoResDto(0, 0, 0)) + ); + + when(myPageService.findTeamDashboardsAndChallenges(anyString(), any())).thenReturn(resDto); + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + + mockMvc.perform(get("/api/members/mypage/dashboard-challenges") + .header("Authorization", "Bearer valid-token") + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andDo(document("member/team-challenges", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)"), + parameterWithName("size").description("페이지 당 항목 수 (기본값: 10)") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.teamDashboardList.teamDashboardInfoResDto").description("팀 대시보드 정보 목록"), + fieldWithPath("data.teamDashboardList.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.teamDashboardList.pageInfoResDto.totalPages").description("총 페이지 수"), + fieldWithPath("data.teamDashboardList.pageInfoResDto.totalItems").description("총 항목 수"), + fieldWithPath("data.challengeList.challengeInfoResDto").description("챌린지 정보 목록"), + fieldWithPath("data.challengeList.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.challengeList.pageInfoResDto.totalPages").description("총 페이지 수"), + fieldWithPath("data.challengeList.pageInfoResDto.totalItems").description("총 항목 수") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("팀 대시보드와 챌린지 정보 조회"))) + .andExpect(jsonPath("$.data").exists()); + } + +// @DisplayName("PATCH 내 프로필 정보 수정 테스트") +// @Test +// void 내_프로필_정보_수정() throws Exception { +// // given +// MyPageUpdateReqDto updateReqDto = new MyPageUpdateReqDto("귀여운수달", "안녕하세요?"); +// +// member.update(updateReqDto.nickname(), updateReqDto.introduction()); +// +// MyPageInfoResDto myPageInfoResDto = MyPageInfoResDto.From(member); +// +// given(myPageService.update(anyString(), any(MyPageUpdateReqDto.class))) +// .willReturn(myPageInfoResDto); +// +// // when & then +// mockMvc.perform(patch("/api/members/mypage") +// .header("Authorization", "Bearer valid-token") +// .contentType(MediaType.APPLICATION_JSON) +// .content(objectMapper.writeValueAsString(updateReqDto))) +// .andDo(print()) +// .andDo(document("member/mypage-update", +// preprocessRequest(prettyPrint()), +// preprocessResponse(prettyPrint()), +// requestHeaders( +// headerWithName("Authorization").description("JWT 토큰") +// ), +// requestFields( +// fieldWithPath("nickname").description("변경할 닉네임"), +// fieldWithPath("introduction").description("변경할 소개") +// ), +// responseFields( +// fieldWithPath("statusCode").description("상태 코드"), +// fieldWithPath("message").description("응답 메시지"), +// fieldWithPath("data.picture").description("회원 사진"), +// fieldWithPath("data.email").description("회원 이메일"), +// fieldWithPath("data.name").description("회원 이름"), +// fieldWithPath("data.nickName").description("회원 닉네임"), +// fieldWithPath("data.socialType").description("회원 소셜 타입"), +// fieldWithPath("data.introduction").description("회원 소개") +// ) +// )) +// .andExpect(status().isOk()); +// } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageServiceTest.java new file mode 100644 index 00000000..d81ba853 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageServiceTest.java @@ -0,0 +1,132 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; + +import java.util.Collections; +import java.util.Optional; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(MockitoExtension.class) +public class MyPageServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private TeamDashboardService teamDashboardService; + + @Mock + private ChallengeService challengeService; + + @InjectMocks + private MyPageService myPageService; + + private MyPageUpdateReqDto myPageUpdateReqDto; + private MyPageInfoResDto myPageInfoResDto; + private Member member; + private Pageable pageable; + private TeamDashboardListResDto teamDashboardListResDto; + private ChallengeListResDto challengeListResDto; + + @BeforeEach + void setUp() { + myPageUpdateReqDto = new MyPageUpdateReqDto("nickname", "introduction"); + myPageInfoResDto = new MyPageInfoResDto("picture", "email", "name", "nickname", SocialType.GOOGLE, "introduction"); + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + pageable = PageRequest.of(0, 10); + + teamDashboardListResDto = TeamDashboardListResDto.of( + Collections.emptyList(), + null + ); + + challengeListResDto = ChallengeListResDto.of( + Collections.emptyList(), + null + ); + } + + @DisplayName("프로필을 조회합니다.") + @Test + void 프로필을_조회합니다() { + // Given + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + + // When + MyPageInfoResDto result = myPageService.findMyProfileByEmail("email"); + + // Then + assertEquals(myPageInfoResDto.email(), result.email()); + assertEquals(myPageInfoResDto.name(), result.name()); + assertEquals(myPageInfoResDto.nickName(), result.nickName()); + assertEquals(myPageInfoResDto.socialType(), result.socialType()); + assertEquals(myPageInfoResDto.introduction(), result.introduction()); + assertEquals(myPageInfoResDto.picture(), result.picture()); + } + + // 프로필 정보 수정 + @DisplayName("프로필 정보를 수정합니다.") + @Test + void 프로필_정보를_수정합니다() { + // Given + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + MyPageUpdateReqDto newMyPageUpdateReqDto = new MyPageUpdateReqDto("newNickname", "newIntroduction"); + // When + MyPageInfoResDto result = myPageService.update("email", newMyPageUpdateReqDto); + + // Then + assertEquals("newNickname", result.nickName()); + assertEquals("newIntroduction", result.introduction()); + + verify(memberRepository, times(1)).findByEmail("email"); + } + + @DisplayName("팀 대시보드와 챌린지 정보를 조회합니다.") + @Test + void 팀_대시보드와_챌린지_정보를_조회합니다() { + // Given + String email = "test@example.com"; + + when(teamDashboardService.findForTeamDashboard(email, pageable)).thenReturn(teamDashboardListResDto); + when(challengeService.findChallengeForMemberId(email, pageable)).thenReturn(challengeListResDto); + + // When + TeamDashboardsAndChallengesResDto result = myPageService.findTeamDashboardsAndChallenges(email, pageable); + + // Then + assertEquals(teamDashboardListResDto, result.teamDashboardList()); + assertEquals(challengeListResDto, result.challengeList()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameServiceTest.java new file mode 100644 index 00000000..8008c9ac --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameServiceTest.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.member.nickname.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NicknameServiceTest { + + @Mock + private Random random; + + @InjectMocks + private NicknameService nicknameService; + + @BeforeEach + void setUp() { + nicknameService = new NicknameService( + List.of("행복한", "귀여운", "깜찍한"), + List.of("고양이", "인호", "토끼"), + random + ); + } + + @DisplayName("랜덤 닉네임을 생성합니다.") + @Test + void 랜덤_닉네임을_생성합니다() { + when(random.nextInt(anyInt())).thenReturn(0, 1); + + String nickname = nicknameService.getRandomNickname(); + + assertThat(nickname).isEqualTo("행복한인호"); + verify(random, times(2)).nextInt(anyInt()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationControllerTest.java new file mode 100644 index 00000000..a7097eaa --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationControllerTest.java @@ -0,0 +1,184 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationInfoResDto; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +@ExtendWith(RestDocumentationExtension.class) +class NotificationControllerTest extends ControllerTest { + + private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; + private static final String AUTHORIZATION_HEADER_VALUE = "Bearer valid-token"; + + private Member member; + private Notification notification; + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + + @Mock + private NotificationService notificationService; + + @InjectMocks + private NotificationController notificationController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + notification = Notification.builder() + .receiver(member) + .message("테스트 알림") + .isRead(false) + .build(); + + mockMvc = MockMvcBuilders.standaloneSetup(notificationController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()).build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("kkeujeok@gmail.com"); + } + + @Test + @DisplayName("사용자는 SSE 발행기를 생성시 상태코드 200 반환.") + void 사용자는_SSE_발행기를_생성시_상태코드_200_반환() throws Exception { + // given + given(notificationService.createEmitter(member.getEmail())).willReturn(emitter); + + // when & then + mockMvc.perform(get("/api/notifications/stream") + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(member.getEmail())) + .andDo(print()) + .andDo(document("notification/stream", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("알림 전체 조회에 성공하면 상태코드 200 반환") + void 알림_전체_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + NotificationInfoResDto notificationInfoResDto = NotificationInfoResDto.from(notification); + Page notificationPage = new PageImpl<>(List.of(notification), PageRequest.of(0, 10), 1); + NotificationListResDto response = NotificationListResDto.of(List.of(notificationInfoResDto), + PageInfoResDto.from(notificationPage)); + + given(notificationService.findAllNotificationsFromMember(member.getEmail(), PageRequest.of(0, 10))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/notifications") + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(member.getEmail())) + .andDo(print()) + .andDo(document("notification/findAll", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.notificationInfoResDto[].id").description("알림 아이디"), + fieldWithPath("data.notificationInfoResDto[].message").description("알림 메시지"), + fieldWithPath("data.notificationInfoResDto[].isRead").description("알림 읽은 여부"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 개수") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("알림 상세 조회에 성공하면 상태코드 200 반환") + void 알림_상세_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + NotificationInfoResDto notificationInfoResDto = NotificationInfoResDto.from(notification); + given(notificationService.findByNotificationId(anyLong())).willReturn(notificationInfoResDto); + + // when & then + mockMvc.perform( + get("/api/notifications/{notificationId}", 1L) + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON) + .content(member.getEmail())) + .andDo(print()) + .andDo(document("notification/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("notificationId").description("알림 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.id").description("알림 아이디"), + fieldWithPath("data.message").description("알림 메시지"), + fieldWithPath("data.isRead").description("알림 읽은 여부") + )) + ) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationServiceTest.java new file mode 100644 index 00000000..f712e115 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationServiceTest.java @@ -0,0 +1,138 @@ +package shop.kkeujeok.kkeujeokbackend.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; +import shop.kkeujeok.kkeujeokbackend.notification.domain.repository.NotificationRepository; +import shop.kkeujeok.kkeujeokbackend.notification.exception.NotificationNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.util.SseEmitterManager; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private SseEmitter emitter; + + @Mock + private SseEmitterManager sseEmitterManager; + + @InjectMocks + private NotificationService notificationService; + + private Member member; + private Notification notification; + + @BeforeEach + void setUp() { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + notification = Notification.builder() + .receiver(member) + .message("Test Notification") + .isRead(false) + .build(); + + emitter = new SseEmitter(Long.MAX_VALUE); + } + + @Test + @DisplayName("회원은 SSE 발행기를 생성할 수 있다") + void 회원은_SSE_발행기를_생성할_수_있다() { + // given + when(memberRepository.findByEmail(anyString())) + .thenReturn(Optional.of(member)); + + when(sseEmitterManager.createEmitter(member.getId())) + .thenReturn(emitter); + + // when + SseEmitter result = notificationService.createEmitter(member.getEmail()); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("알림을 회원에게 보낼 수 있다") + void 알림을_회원에게_보낼_수_있다() { + // given + when(notificationRepository.save(any(Notification.class))) + .thenReturn(notification); + + // when + notificationService.sendNotification(member, "새로운 알림"); + + // then + // 어떻게 테스트할 지 고민해보겠습니다.. + } + + @Test + @DisplayName("존재하지 않는 알림을 조회하면 예외가 발생한다") + void 존재하지_않는_알림을_조회하면_예외가_발생한다() { + // given + Long nonExistentNotificationId = 999L; + when(notificationRepository.findById(nonExistentNotificationId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationService.findByNotificationId(nonExistentNotificationId)) + .isInstanceOf(NotificationNotFoundException.class); + } + + @Test + @DisplayName("회원의 모든 알림을 조회할 수 있다") + void 회원의_모든_알림을_조회할_수_있다() { + // given + List notifications = List.of(notification); + when(notificationRepository.findAllNotifications(any(Member.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(notifications)); + when(memberRepository.findByEmail(anyString())) + .thenReturn(Optional.of(member)); + + // when + NotificationListResDto result = notificationService.findAllNotificationsFromMember(member.getEmail(), + Pageable.unpaged()); + + // then + assertThat(result.notificationInfoResDto()).isNotEmpty(); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(notifications.size()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..03c30d37 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: test