From d53ce07a8ce963282ad4f6837f910a57514f1a52 Mon Sep 17 00:00:00 2001 From: Pak Su Hyung Date: Tue, 19 Nov 2024 15:45:45 +0900 Subject: [PATCH] =?UTF-8?q?[CICD]=20AWS=20=EB=B0=B0=ED=8F=AC=20CICD=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20(#494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ApiFolderErrorCode 오타 수정 (#468) * [REFACTOR] 픽 리팩토링, 검색 테스트 구현 (#474) * refactor: FolderType label 수정 * refactor: 파라미터 idList -> pickIdList 명시적으로 변경 * refactor: folder 내부에 있는 pick_order 리스트에 pickId가 중복 생성되는 문제 해결 * refactor: root folder 검색 못하도록 예외 처리 * refactor: pick controller 메서드명 수정 * refactor: DB fleid 함수를 이용하여 정렬된 픽 리스트 조회할 수 있는 메서드 구현 * refactor: 픽 검색 시 폴더 리스트가 null인 경우 검증하지 않도록 변경, tag 검증 추가 * refactor: 테스트 코드 작성 * refactor: 테스트 코드 ParameterizedTest로 리팩토링 (#476) * [FIX] 픽 수정 리팩토링 및 검증로직 개선 (#477) * refactor: 불필요한 초기값 제거 * fix: 엔티티를 list로 조회시 존재하지 않는 엔티티를 조회하려고 하면 NOT_FOUND 예외 발생 * fix: pick update시 부모폴더 id도 변경 가능하도록 수정, 누락된 검증 로직 추가 * fix: 기능 변경에 의한 테스트 코드 수정 * fix: idList로 폴더 조회시 존재하지 않는 폴더id로 조회하면 예외 발생 * refactor: 파라미터명 명확하게 수정 idList -> folderIdList * feat: 폴더 응답에 생성 수정 일자 추가 (#485) * cicd: aws 배포 action 추가 --------- Co-authored-by: Sangwon Yang --- .github/workflows/aws-api-module-deploy.yml | 102 ++++ .github/workflows/aws-batch-module-deploy.yml | 102 ++++ .../folder/dto/FolderApiResponse.java | 5 +- .../pick/controller/PickApiController.java | 2 +- .../application/pick/dto/PickApiRequest.java | 1 + .../api/domain/folder/dto/FolderResult.java | 5 +- .../folder/exception/ApiFolderErrorCode.java | 9 +- .../folder/exception/ApiFolderException.java | 5 + .../api/domain/pick/dto/PickCommand.java | 2 +- .../pick/service/PickSearchService.java | 37 +- .../api/domain/pick/service/PickService.java | 32 +- .../folder/FolderDataHandler.java | 19 +- .../infrastructure/pick/PickDataHandler.java | 60 +-- .../api/infrastructure/pick/PickQuery.java | 31 +- .../infrastructure/tag/TagDataHandler.java | 9 +- .../domain/pick/service/PickSearchTest.java | 499 ++++++++++++++++++ .../domain/pick/service/PickServiceTest.java | 15 +- .../techpick/api/fixture/PickFixture.java | 2 +- .../infrastructure/pick/PickQueryTest.java | 166 ------ .../core/model/folder/FolderType.java | 8 +- 20 files changed, 856 insertions(+), 255 deletions(-) create mode 100644 .github/workflows/aws-api-module-deploy.yml create mode 100644 .github/workflows/aws-batch-module-deploy.yml create mode 100644 backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickSearchTest.java delete mode 100644 backend/techpick-api/src/test/java/techpick/api/infrastructure/pick/PickQueryTest.java diff --git a/.github/workflows/aws-api-module-deploy.yml b/.github/workflows/aws-api-module-deploy.yml new file mode 100644 index 000000000..59dcdfef9 --- /dev/null +++ b/.github/workflows/aws-api-module-deploy.yml @@ -0,0 +1,102 @@ +name: Tech-pick Api-Module CI/CD + +on: + push: + branches: + - "release" # 배포 대상 브랜치 + paths: + - 'backend/techpick-core/**' + - 'backend/techpick-api/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + env: + api-version: 'v2' + + steps: + # 저장소 Checkout + - name: Checkout source code + uses: actions/checkout@v4 + + # Gradle 실행 권한 부여 + - name: Grant execute permission to gradlew + run: chmod +x ./backend/gradlew + + # JDK 설치 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + + # Spring boot application, Docker image 빌드 + - name: Build and Deploy techpick-api Module + run: | + echo "Building and deploying techpick-api..." + ./backend/gradlew -p backend/techpick-api clean build -x test + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-api-${{ github.sha }} backend/techpick-api + + - # Docker hub 로그인 + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - # Docker hub 업로드 + name: Publish to docker hub + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-api-${{ github.sha }} + + - name: Deploy on AWS + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.AWS_API_SERVER_IP }} + port: 22 + username: ${{ secrets.AWS_USERNAME }} + key: ${{ secrets.AWS_ACCESS_KEY }} + script: | + echo "Login to Docker Hub for private repository access on bastion..." + echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + echo "Pulling Docker image on bastion..." + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-api-${{ github.sha }} + + echo "Tagging Docker image..." + docker tag minkyeki/techpick:${{ env.api-version }}-api-${{ github.sha }} techpick:${{ env.api-version }}-api-staging + + cd /home/ubuntu + rm -rf .env + touch .env + echo "DOCKER_MYSQL_USERNAME=${{ secrets.DOCKER_MYSQL_USERNAME }}" >> .env + echo "DOCKER_MYSQL_PASSWORD=${{ secrets.DOCKER_MYSQL_PASSWORD }}" >> .env + echo "DOCKER_MYSQL_DATABASE=${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}" >> .env + echo "DOCKER_MYSQL_URL=jdbc:mysql://techpick-mysql:3306/${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}?createDatabaseIfNotExist=true" >> .env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env + echo "JWT_ISSUER=${{ secrets.JWT_ISSUER }}" >> .env + echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env + echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env + echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env + echo "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" >> .env + echo "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" >> .env + + echo "Restarting techpick-api service..." + docker-compose stop techpick-api + docker-compose rm -f techpick-api + docker-compose up -d techpick-api + + echo "Cleanup Images..." + docker image prune -af + + - name: Discord Webhook Notification + uses: sarisia/actions-status-discord@v1.14.7 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: ${{ job.status }} + title: "TechPick ${{ env.api-version }} - Api Deployment Result" + description: "AWS 배포가 완료되었습니다." + color: 0xff91a4 + url: "https://github.com/sarisia/actions-status-discord" + username: GitHub Actions \ No newline at end of file diff --git a/.github/workflows/aws-batch-module-deploy.yml b/.github/workflows/aws-batch-module-deploy.yml new file mode 100644 index 000000000..f2777afd6 --- /dev/null +++ b/.github/workflows/aws-batch-module-deploy.yml @@ -0,0 +1,102 @@ +name: Tech-pick Batch-Module CI/CD + +on: + push: + branches: + - "release" # 배포 대상 브랜치 + paths: + - 'backend/techpick-core/**' + - 'backend/techpick-batch/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + env: + api-version: 'v2' + + steps: + # 저장소 Checkout + - name: Checkout source code + uses: actions/checkout@v4 + + # Gradle 실행 권한 부여 + - name: Grant execute permission to gradlew + run: chmod +x ./backend/gradlew + + # JDK 설치 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + + # Spring boot application, Docker image 빌드 + - name: Build and Deploy techpick-batch Module + run: | + echo "Building and deploying techpick-batch..." + ./backend/gradlew -p backend/techpick-batch clean build -x test + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-batch-${{ github.sha }} backend/techpick-batch + + - # Docker hub 로그인 + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - # Docker hub 업로드 + name: Publish to docker hub + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-batch-${{ github.sha }} + + - name: Deploy on AWS + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.AWS_API_SERVER_IP }} + port: 22 + username: ${{ secrets.AWS_USERNAME }} + key: ${{ secrets.AWS_ACCESS_KEY }} + script: | + echo "Login to Docker Hub for private repository access on bastion..." + echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + echo "Pulling Docker image on bastion..." + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-batch-${{ github.sha }} + + echo "Tagging Docker image..." + docker tag minkyeki/techpick:${{ env.api-version }}-batch-${{ github.sha }} techpick:${{ env.api-version }}-batch-staging + + cd /home/ubuntu + rm -rf .env + touch .env + echo "DOCKER_MYSQL_USERNAME=${{ secrets.DOCKER_MYSQL_USERNAME }}" >> .env + echo "DOCKER_MYSQL_PASSWORD=${{ secrets.DOCKER_MYSQL_PASSWORD }}" >> .env + echo "DOCKER_MYSQL_DATABASE=${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}" >> .env + echo "DOCKER_MYSQL_URL=jdbc:mysql://techpick-mysql:3306/${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}?createDatabaseIfNotExist=true" >> .env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env + echo "JWT_ISSUER=${{ secrets.JWT_ISSUER }}" >> .env + echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env + echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env + echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env + echo "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" >> .env + echo "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" >> .env + + echo "Restarting techpick-batch service..." + docker-compose stop techpick-batch + docker-compose rm -f techpick-batch + docker-compose up -d techpick-batch + + echo "Cleanup Images..." + docker image prune -af + + - name: Discord Webhook Notification + uses: sarisia/actions-status-discord@v1.14.7 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: ${{ job.status }} + title: "TechPick ${{ env.api-version }} - Batch Deployment Result" + description: "AWS 배포가 완료되었습니다." + color: 0xff91a4 + url: "https://github.com/sarisia/actions-status-discord" + username: GitHub Actions \ No newline at end of file diff --git a/backend/techpick-api/src/main/java/techpick/api/application/folder/dto/FolderApiResponse.java b/backend/techpick-api/src/main/java/techpick/api/application/folder/dto/FolderApiResponse.java index 5f0a971a8..6b7623812 100644 --- a/backend/techpick-api/src/main/java/techpick/api/application/folder/dto/FolderApiResponse.java +++ b/backend/techpick-api/src/main/java/techpick/api/application/folder/dto/FolderApiResponse.java @@ -1,5 +1,6 @@ package techpick.api.application.folder.dto; +import java.time.LocalDateTime; import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,6 +12,8 @@ public record FolderApiResponse( @Schema(example = "GENERAL") FolderType folderType, Long parentFolderId, - List childFolderIdOrderedList + List childFolderIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { } diff --git a/backend/techpick-api/src/main/java/techpick/api/application/pick/controller/PickApiController.java b/backend/techpick-api/src/main/java/techpick/api/application/pick/controller/PickApiController.java index 9a3140a24..00326f59e 100644 --- a/backend/techpick-api/src/main/java/techpick/api/application/pick/controller/PickApiController.java +++ b/backend/techpick-api/src/main/java/techpick/api/application/pick/controller/PickApiController.java @@ -62,7 +62,7 @@ public ResponseEntity> getFolderChildPickLi @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공") }) - public ResponseEntity> searchPickAndRssList( + public ResponseEntity> searchPick( @LoginUserId Long userId, @Parameter(description = "조회할 폴더 ID 목록", example = "1, 2, 3") @RequestParam(required = false, defaultValue = "") List folderIdList, @Parameter(description = "검색 토큰 목록", example = "리액트, 쿼리, 서버") @RequestParam(required = false, defaultValue = "") List searchTokenList, diff --git a/backend/techpick-api/src/main/java/techpick/api/application/pick/dto/PickApiRequest.java b/backend/techpick-api/src/main/java/techpick/api/application/pick/dto/PickApiRequest.java index e59122644..bddaa80cf 100644 --- a/backend/techpick-api/src/main/java/techpick/api/application/pick/dto/PickApiRequest.java +++ b/backend/techpick-api/src/main/java/techpick/api/application/pick/dto/PickApiRequest.java @@ -24,6 +24,7 @@ public record Read( public record Update( @Schema(example = "1") @NotNull Long id, @Schema(example = "Record란 뭘까?") String title, + @Schema(example = "3") Long parentFolderId, @Schema(example = "[4, 5, 2, 1]") List tagIdOrderedList ) { } diff --git a/backend/techpick-api/src/main/java/techpick/api/domain/folder/dto/FolderResult.java b/backend/techpick-api/src/main/java/techpick/api/domain/folder/dto/FolderResult.java index 4d08d47c5..d325133a2 100644 --- a/backend/techpick-api/src/main/java/techpick/api/domain/folder/dto/FolderResult.java +++ b/backend/techpick-api/src/main/java/techpick/api/domain/folder/dto/FolderResult.java @@ -1,5 +1,6 @@ package techpick.api.domain.folder.dto; +import java.time.LocalDateTime; import java.util.List; import techpick.core.model.folder.FolderType; @@ -11,6 +12,8 @@ public record FolderResult( Long parentFolderId, Long userId, List childFolderIdOrderedList, - List childPickIdOrderedList + List childPickIdOrderedList, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { } diff --git a/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderErrorCode.java b/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderErrorCode.java index 56d2f5a74..9bc129bdb 100644 --- a/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderErrorCode.java +++ b/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderErrorCode.java @@ -21,13 +21,16 @@ public enum ApiFolderErrorCode implements ApiErrorCode { CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN ("FO-004", HttpStatus.BAD_REQUEST, "휴지통 안에 있는 폴더만 삭제할 수 있음", ErrorLevel.MUST_NEVER_HAPPEN()), INVALID_FOLDER_TYPE - ("F0-005", HttpStatus.NOT_IMPLEMENTED, "미구현 폴더 타입에 대한 서비스 요청", ErrorLevel.MUST_NEVER_HAPPEN()), + ("FO-005", HttpStatus.NOT_IMPLEMENTED, "미구현 폴더 타입에 대한 서비스 요청", ErrorLevel.MUST_NEVER_HAPPEN()), BASIC_FOLDER_ALREADY_EXISTS - ("F0-006", HttpStatus.NOT_ACCEPTABLE, "기본 폴더는 1개만 존재할 수 있음.", ErrorLevel.MUST_NEVER_HAPPEN()), + ("FO-006", HttpStatus.NOT_ACCEPTABLE, "기본 폴더는 1개만 존재할 수 있음.", ErrorLevel.MUST_NEVER_HAPPEN()), INVALID_MOVE_TARGET - ("F0-007", HttpStatus.NOT_ACCEPTABLE, "이동하려는 폴더들의 범위가 올바르지 않음", ErrorLevel.SHOULD_NOT_HAPPEN()), + ("FO-007", HttpStatus.NOT_ACCEPTABLE, "이동하려는 폴더들의 범위가 올바르지 않음", ErrorLevel.SHOULD_NOT_HAPPEN()), INVALID_PARENT_FOLDER ("FO-008", HttpStatus.NOT_ACCEPTABLE, "부모 폴더가 올바르지 않음", ErrorLevel.SHOULD_NOT_HAPPEN()), + // TODO: folder depth 추가 시 예외 삭제 예정 + ROOT_FOLDER_SEARCH_NOT_ALLOWED + ("FO-009", HttpStatus.NOT_ACCEPTABLE, "루트 폴더에 대한 검색은 허용되지 않음.", ErrorLevel.SHOULD_NOT_HAPPEN()), ; private final String code; diff --git a/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderException.java b/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderException.java index 0a99a779e..994c989a2 100644 --- a/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderException.java +++ b/backend/techpick-api/src/main/java/techpick/api/domain/folder/exception/ApiFolderException.java @@ -47,4 +47,9 @@ public static ApiFolderException INVALID_MOVE_TARGET() { public static ApiFolderException INVALID_PARENT_FOLDER() { return new ApiFolderException(ApiFolderErrorCode.INVALID_PARENT_FOLDER); } + + public static ApiFolderException ROOT_FOLDER_SEARCH_NOT_ALLOWED() { + return new ApiFolderException(ApiFolderErrorCode.ROOT_FOLDER_SEARCH_NOT_ALLOWED); + } + } diff --git a/backend/techpick-api/src/main/java/techpick/api/domain/pick/dto/PickCommand.java b/backend/techpick-api/src/main/java/techpick/api/domain/pick/dto/PickCommand.java index f08eab0c8..9cabfe8e5 100644 --- a/backend/techpick-api/src/main/java/techpick/api/domain/pick/dto/PickCommand.java +++ b/backend/techpick-api/src/main/java/techpick/api/domain/pick/dto/PickCommand.java @@ -20,7 +20,7 @@ public record Create(Long userId, String title, List tagIdOrderedList, Lon LinkInfo linkInfo) { } - public record Update(Long userId, Long id, String title, List tagIdOrderedList) { + public record Update(Long userId, Long id, String title, Long parentFolderId, List tagIdOrderedList) { } public record Move(Long userId, List idList, Long destinationFolderId, int orderIdx) { diff --git a/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickSearchService.java b/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickSearchService.java index 8dbccf132..e3c1905a0 100644 --- a/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickSearchService.java +++ b/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickSearchService.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.apache.commons.lang3.ObjectUtils; import org.springframework.data.domain.Slice; @@ -12,9 +13,13 @@ import techpick.api.domain.folder.exception.ApiFolderException; import techpick.api.domain.pick.dto.PickCommand; import techpick.api.domain.pick.dto.PickResult; +import techpick.api.domain.tag.exception.ApiTagException; import techpick.api.infrastructure.folder.FolderDataHandler; import techpick.api.infrastructure.pick.PickQuery; +import techpick.api.infrastructure.tag.TagDataHandler; import techpick.core.model.folder.Folder; +import techpick.core.model.folder.FolderType; +import techpick.core.model.tag.Tag; @Service @RequiredArgsConstructor @@ -22,12 +27,24 @@ public class PickSearchService { private final PickQuery pickQuery; private final FolderDataHandler folderDataHandler; + private final TagDataHandler tagDataHandler; @Transactional(readOnly = true) public Slice searchPick(PickCommand.Search command) { List folderIdList = command.folderIdList(); - for (Long folderId : folderIdList) { - validateFolderAccess(command.userId(), folderId); + List tagIdList = command.tagIdList(); + + if (ObjectUtils.isNotEmpty(folderIdList)) { + for (Long folderId : folderIdList) { + validateFolderAccess(command.userId(), folderId); + validateFolderRootSearch(folderId); + } + } + + if (ObjectUtils.isNotEmpty(tagIdList)) { + for (Long tagId : tagIdList) { + validateTagAccess(command.userId(), tagId); + } } return pickQuery.searchPick(command.userId(), folderIdList, @@ -36,9 +53,23 @@ public Slice searchPick(PickCommand.Search command) { } private void validateFolderAccess(Long userId, Long folderId) { - Folder parentFolder = folderDataHandler.getFolder(folderId); + Folder parentFolder = folderDataHandler.getFolder(folderId); // 존재하지 않으면, FOLDER_NOT_FOUND if (ObjectUtils.notEqual(userId, parentFolder.getUser().getId())) { throw ApiFolderException.FOLDER_ACCESS_DENIED(); } } + + private void validateFolderRootSearch(Long folderId) { + Folder parentFolder = folderDataHandler.getFolder(folderId); // 존재하지 않으면, FOLDER_NOT_FOUND + if (Objects.equals(parentFolder.getFolderType(), FolderType.ROOT)) { + throw ApiFolderException.ROOT_FOLDER_SEARCH_NOT_ALLOWED(); + } + } + + private void validateTagAccess(Long userId, Long tagId) { + Tag tag = tagDataHandler.getTag(tagId); // 존재하지 않으면, TAG_NOT_FOUND + if (!userId.equals(tag.getUser().getId())) { + throw ApiTagException.UNAUTHORIZED_TAG_ACCESS(); + } + } } diff --git a/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickService.java b/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickService.java index f7e507be6..1765292b6 100644 --- a/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickService.java +++ b/backend/techpick-api/src/main/java/techpick/api/domain/pick/service/PickService.java @@ -73,24 +73,16 @@ public List getFolderListChildPickList(PickCommand.Re public PickResult.Pick saveNewPick(PickCommand.Create command) { validateRootAccess(command.parentFolderId()); validateFolderAccess(command.userId(), command.parentFolderId()); - var pick = pickDataHandler.savePick(command); - pick.getParentFolder().getChildPickIdOrderedList().add(pick.getId()); + validateTagListAccess(command.userId(), command.tagIdOrderedList()); - List tagOrderList = pick.getTagIdOrderedList(); - List tagList = tagDataHandler.getTagList(tagOrderList); - for (Tag tag : tagList) { - if (ObjectUtils.notEqual(tag.getUser(), pick.getUser())) { - throw ApiTagException.UNAUTHORIZED_TAG_ACCESS(); - } - pickDataHandler.savePickTag(pick, tag); - } - - return pickMapper.toPickResult(pick); + return pickMapper.toPickResult(pickDataHandler.savePick(command)); } @Transactional public PickResult.Pick updatePick(PickCommand.Update command) { validatePickAccess(command.userId(), command.id()); + validateFolderAccess(command.userId(), command.parentFolderId()); + validateTagListAccess(command.userId(), command.tagIdOrderedList()); return pickMapper.toPickResult(pickDataHandler.updatePick(command)); } @@ -146,6 +138,10 @@ private void validatePickAccess(Long userId, Long pickId) { } private void validateFolderAccess(Long userId, Long folderId) { + // folderId가 null인 경우 변경이 없는 것이니 검증하지 않음 + if (folderId == null) { + return; + } Folder parentFolder = folderDataHandler.getFolder(folderId); if (ObjectUtils.notEqual(userId, parentFolder.getUser().getId())) { throw ApiFolderException.FOLDER_ACCESS_DENIED(); @@ -157,4 +153,16 @@ private void validateRootAccess(Long parentFolderId) { throw ApiPickException.PICK_UNAUTHORIZED_ROOT_ACCESS(); } } + + private void validateTagListAccess(Long userId, List tagIdList) { + // tagIdList가 null인 경우 변경이 없는 것이니 검증하지 않음 + if (tagIdList == null) { + return; + } + for (Tag tag : tagDataHandler.getTagList(tagIdList)) { + if (ObjectUtils.notEqual(userId, tag.getUser().getId())) { + throw ApiTagException.UNAUTHORIZED_TAG_ACCESS(); + } + } + } } diff --git a/backend/techpick-api/src/main/java/techpick/api/infrastructure/folder/FolderDataHandler.java b/backend/techpick-api/src/main/java/techpick/api/infrastructure/folder/FolderDataHandler.java index 16a081d1b..e59098bb8 100644 --- a/backend/techpick-api/src/main/java/techpick/api/infrastructure/folder/FolderDataHandler.java +++ b/backend/techpick-api/src/main/java/techpick/api/infrastructure/folder/FolderDataHandler.java @@ -30,15 +30,24 @@ public Folder getFolder(Long folderId) { // idList에 포함된 모든 ID에 해당하는 폴더 리스트 조회, 순서를 보장하지 않음 @Transactional(readOnly = true) - public List getFolderList(List idList) { - return folderRepository.findAllById(idList); + public List getFolderList(List folderIdList) { + List folderList = folderRepository.findAllById(folderIdList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (folderList.size() != folderIdList.size()) { + throw ApiFolderException.FOLDER_NOT_FOUND(); + } + return folderList; } // idList에 포함된 모든 ID에 해당하는 폴더 리스트 조회, 순서는 idList의 순서를 따름 @Transactional(readOnly = true) - public List getFolderListPreservingOrder(List idList) { - var folderList = folderRepository.findAllById(idList); - folderList.sort(Comparator.comparing(folder -> idList.indexOf(folder.getId()))); + public List getFolderListPreservingOrder(List folderIdList) { + List folderList = folderRepository.findAllById(folderIdList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (folderList.size() != folderIdList.size()) { + throw ApiFolderException.FOLDER_NOT_FOUND(); + } + folderList.sort(Comparator.comparing(folder -> folderIdList.indexOf(folder.getId()))); return folderList; } diff --git a/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickDataHandler.java b/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickDataHandler.java index 7095ee660..d802b26ba 100644 --- a/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickDataHandler.java +++ b/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickDataHandler.java @@ -2,9 +2,7 @@ import java.util.Comparator; import java.util.List; -import java.util.Objects; -import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -56,14 +54,23 @@ public Pick getPickUrl(Long userId, String url) { } @Transactional(readOnly = true) - public List getPickList(List idList) { - return pickRepository.findAllById(idList); + public List getPickList(List pickIdList) { + List pickList = pickRepository.findAllById(pickIdList); + // 조회리스트에 존재하지 않는 픽이 있으면 예외 발생 + if (pickList.size() != pickIdList.size()) { + throw ApiPickException.PICK_NOT_FOUND(); + } + return pickList; } @Transactional(readOnly = true) - public List getPickListPreservingOrder(List idList) { - List pickList = pickRepository.findAllById(idList); - pickList.sort(Comparator.comparing(pick -> idList.indexOf(pick.getId()))); + public List getPickListPreservingOrder(List pickIdList) { + List pickList = pickRepository.findAllById(pickIdList); + // 조회리스트에 존재하지 않는 픽이 있으면 예외 발생 + if (pickList.size() != pickIdList.size()) { + throw ApiPickException.PICK_NOT_FOUND(); + } + pickList.sort(Comparator.comparing(pick -> pickIdList.indexOf(pick.getId()))); return pickList; } @@ -91,32 +98,28 @@ public Pick savePick(PickCommand.Create command) throws ApiPickException { throw ApiPickException.PICK_MUST_BE_UNIQUE_FOR_A_URL(); }); - // 태그 존재 여부 검증 - validateTagIdList(command.tagIdOrderedList()); - Pick savedPick = pickRepository.save(pickMapper.toEntity(command, user, folder, link)); savedPick.getParentFolder().addChildPickIdOrdered(savedPick.getId()); - return savedPick; - } + List pickTagList = tagRepository.findAllById(command.tagIdOrderedList()) + .stream() + .map(tag -> PickTag.of(savedPick, tag)) + .toList(); + pickTagRepository.saveAll(pickTagList); - @Transactional - public PickTag savePickTag(Pick pick, Tag tag) { - return pickTagRepository.save(PickTag.of(pick, tag)); + return savedPick; } @Transactional public Pick updatePick(PickCommand.Update command) { Pick pick = getPick(command.id()); pick.updateTitle(command.title()); - - // 제목만 수정하는 경우 - if (ObjectUtils.isNotEmpty(command.title()) && Objects.isNull(command.tagIdOrderedList())) { - return pick; + if (command.parentFolderId() != null) { + pick.updateParentFolder( + folderRepository.findById(command.parentFolderId()).orElseThrow(ApiFolderException::FOLDER_NOT_FOUND)); + } + if (command.tagIdOrderedList() != null) { + updateNewTagIdList(pick, command.tagIdOrderedList()); } - - // 태그 존재 여부 검증 - validateTagIdList(command.tagIdOrderedList()); - updateNewTagIdList(pick, command.tagIdOrderedList()); return pick; } @@ -177,11 +180,6 @@ public void detachTagFromPick(Pick pick, Long tagId) { pickTagRepository.deleteByPickAndTagId(pick, tagId); } - @Transactional - public void detachTagFromEveryPick(Long tagId) { - pickTagRepository.deleteByTagId(tagId); - } - private void updateNewTagIdList(Pick pick, List newTagOrderList) { // 1. 기존 태그와 새로운 태그를 비교하여 없어진 태그를 PickTag 테이블에서 제거 pick.getTagIdOrderedList().stream() @@ -196,11 +194,5 @@ private void updateNewTagIdList(Pick pick, List newTagOrderList) { pick.updateTagOrderList(newTagOrderList); } - private void validateTagIdList(List tagIdOrderedList) { - tagIdOrderedList - .forEach(tagId -> tagRepository.findById(tagId) - .orElseThrow(ApiTagException::TAG_NOT_FOUND)); - } - } diff --git a/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickQuery.java b/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickQuery.java index 950faa391..7580a8773 100644 --- a/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickQuery.java +++ b/backend/techpick-api/src/main/java/techpick/api/infrastructure/pick/PickQuery.java @@ -1,5 +1,6 @@ package techpick.api.infrastructure.pick; +import static techpick.core.model.folder.QFolder.*; import static techpick.core.model.pick.QPick.*; import static techpick.core.model.pick.QPickTag.*; @@ -34,7 +35,20 @@ public class PickQuery { private final JPAQueryFactory jpaQueryFactory; // TODO: 폴더 리스트 내 픽 조회 시 java sort vs querydsl 시간 측정 후 빠르면 사용 예정 - public List getPickList(Long userId, List pickIdList) { + // Java sort에 비해 속도가 느린 것을 확인. 참고를 위해 코드 유지 + public List getPickList(Long userId, List folderIdList) { + if (folderIdList == null || folderIdList.isEmpty()) { + return List.of(); + } + + List pickIdList = folderIdList.stream() + .flatMap(folderId -> getChildPickIdOrderedList(folderId).stream()) + .toList(); + + if (pickIdList.isEmpty()) { + return List.of(); + } + String orderListStr = pickIdList.stream() .map(String::valueOf) .collect(Collectors.joining(", ")); @@ -48,8 +62,7 @@ public List getPickList(Long userId, List pickIdList) { .select(pickResultFields()) .from(pick) .where( - userEqCondition(userId), - pickIdListCondition(pickIdList) + userEqCondition(userId) ) .orderBy(orderSpecifier) .fetch(); @@ -115,11 +128,12 @@ private BooleanExpression cursorIdCondition(Long cursorId) { return cursorId == null ? null : pick.id.gt(cursorId); } - private BooleanExpression pickIdListCondition(List pickIdList) { - if (pickIdList == null || pickIdList.isEmpty()) { - return null; - } - return pick.id.in(pickIdList); + private List getChildPickIdOrderedList(Long folderId) { + return jpaQueryFactory + .select(folder.childPickIdOrderedList) + .from(folder) + .where(folder.id.eq(folderId)) + .fetchOne(); } private BooleanExpression folderIdCondition(List folderIdList) { @@ -140,7 +154,6 @@ private BooleanExpression searchTokenListCondition(List searchTokenList) BooleanExpression combinedCondition = null; while (stringTokenizer.hasMoreTokens()) { String part = stringTokenizer.nextToken().toLowerCase(); - // lower() 메서드를 사용하여 pick.title을 소문자로 변환 BooleanExpression condition = pick.title.lower().like("%" + part + "%"); combinedCondition = (combinedCondition == null) ? condition : combinedCondition.and(condition); } diff --git a/backend/techpick-api/src/main/java/techpick/api/infrastructure/tag/TagDataHandler.java b/backend/techpick-api/src/main/java/techpick/api/infrastructure/tag/TagDataHandler.java index 30c43fbae..c36200a77 100644 --- a/backend/techpick-api/src/main/java/techpick/api/infrastructure/tag/TagDataHandler.java +++ b/backend/techpick-api/src/main/java/techpick/api/infrastructure/tag/TagDataHandler.java @@ -6,8 +6,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import lombok.extern.slf4j.Slf4j; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import techpick.api.domain.tag.dto.TagCommand; import techpick.api.domain.tag.dto.TagMapper; import techpick.api.domain.tag.exception.ApiTagException; @@ -47,7 +47,12 @@ public List getTagList(Long userId) { @Transactional(readOnly = true) public List getTagList(List tagOrderList) { - return tagRepository.findAllById(tagOrderList); + List tagList = tagRepository.findAllById(tagOrderList); + // 조회리스트에 존재하지 않는 태그id가 존재하면 예외 발생 + if (tagList.size() != tagOrderList.size()) { + throw ApiTagException.TAG_NOT_FOUND(); + } + return tagList; } @Transactional diff --git a/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickSearchTest.java b/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickSearchTest.java new file mode 100644 index 000000000..c55af5b31 --- /dev/null +++ b/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickSearchTest.java @@ -0,0 +1,499 @@ +package techpick.api.domain.pick.service; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; +import techpick.TechPickApiApplication; +import techpick.api.domain.folder.exception.ApiFolderException; +import techpick.api.domain.link.dto.LinkInfo; +import techpick.api.domain.pick.dto.PickCommand; +import techpick.api.domain.pick.dto.PickResult; +import techpick.api.domain.tag.exception.ApiTagException; +import techpick.core.model.folder.Folder; +import techpick.core.model.folder.FolderRepository; +import techpick.core.model.tag.Tag; +import techpick.core.model.tag.TagRepository; +import techpick.core.model.user.Role; +import techpick.core.model.user.SocialType; +import techpick.core.model.user.User; +import techpick.core.model.user.UserRepository; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@SpringBootTest(classes = TechPickApiApplication.class) +@ActiveProfiles("local") +class PickSearchTest { + + @Autowired + PickSearchService pickSearchService; + + @Autowired + PickService pickService; + + User user1, user2; + Folder root1, recycleBin1, unclassified1, general1; + Folder root2, recycleBin2, unclassified2, general2; + Tag tag1, tag2, tag3, tag4, tag5, tag6; + + @BeforeAll + void setUp( + @Autowired UserRepository userRepository, + @Autowired FolderRepository folderRepository, + @Autowired TagRepository tagRepository + ) { + saveTestUser(userRepository); + saveTestFolder(folderRepository); + saveTestTag(tagRepository); + saveUser1TestPick(); + saveUser2TestPick(); + } + + @ParameterizedTest(name = "다중 검색 조건 테스트 : {index} - {0} ") + @MethodSource("provideSearchTestCases") + void parameterizedSearchTest(TestCase testCase) { + // given + PickCommand.Search search = new PickCommand.Search( + user1.getId(), + testCase.folderIdList, + testCase.searchTokenList, + testCase.tagIdList, + 0L, + 30 + ); + + // when + Slice pickList = pickSearchService.searchPick(search); + + // then + assertThat(pickList).isNotNull(); + assertThat(pickList.getNumberOfElements()).isEqualTo(testCase.expectedCount); + + if (testCase.searchTokenList != null && !testCase.searchTokenList.isEmpty()) { + for (PickResult.Pick pick : pickList) { + boolean containsAllTokens = testCase.searchTokenList.stream() + .allMatch(token -> pick.title().toLowerCase().contains(token.toLowerCase())); + assertThat(containsAllTokens).isTrue(); + } + } + + if (testCase.tagIdList != null && !testCase.tagIdList.isEmpty()) { + for (PickResult.Pick pick : pickList) { + assertThat(pick.tagIdOrderedList()).containsAll(testCase.tagIdList); + } + } + + if (testCase.folderIdList != null && !testCase.folderIdList.isEmpty()) { + for (PickResult.Pick pick : pickList) { + assertThat(testCase.folderIdList).contains(pick.parentFolderId()); + } + } + } + + @MethodSource("provideSearchTestCases") + Stream provideSearchTestCases() { + return Stream.of( + new TestCase(List.of(unclassified1.getId(), recycleBin1.getId()), List.of("리액트", "서버", "스프링"), + List.of(tag1.getId(), tag2.getId()), 10, "모든 조건 검색"), + new TestCase(null, List.of("리액트", "서버", "스프링"), List.of(tag1.getId(), tag2.getId()), 10, "폴더 null"), + new TestCase(List.of(unclassified1.getId(), recycleBin1.getId()), null, List.of(tag1.getId(), tag2.getId()), + 20, "제목 검색 null"), + new TestCase(List.of(unclassified1.getId(), recycleBin1.getId()), List.of("리액트", "서버", "스프링"), null, 10, + "태그 null"), + new TestCase(null, null, List.of(tag1.getId(), tag2.getId()), 20, "폴더, 검색 null"), + new TestCase(null, null, null, 30, "전체 null : 전체 검색"), + new TestCase(List.of(general1.getId()), List.of("s"), null, 10, "폴더 : 일반, 제목 : s"), + new TestCase(List.of(general1.getId()), List.of("T"), null, 5, "폴더 : 일반, 제목 : T"), + new TestCase(List.of(general1.getId()), null, List.of(tag3.getId()), 10, "폴더 : 일반, 태그 : 3"), + new TestCase(List.of(recycleBin1.getId()), null, List.of(tag3.getId()), 0, "폴더 : 휴지통, 태그 : 3") + ); + } + + @Nested + @DisplayName("제목 검색 테스트") + @Transactional + class TitleSearchConditionTest { + + static Stream provideTestCases() { + return Stream.of( + new TestCase(null, List.of("리액트"), null, 10, "리액트"), + new TestCase(null, List.of("스프링"), null, 20, "스프링"), + new TestCase(null, List.of("Spring"), null, 5, "Spring"), + new TestCase(null, List.of("S", "g"), null, 5, "S g"), + new TestCase(null, List.of("S"), null, 10, "S"), + new TestCase(null, List.of("g"), null, 5, "g"), + new TestCase(null, List.of("스", "링"), null, 20, "스 링"), + new TestCase(null, List.of("프링"), null, 20, "프링"), + new TestCase(null, List.of("서버"), null, 10, "서버"), + new TestCase(null, List.of("트"), null, 10, "트"), + new TestCase(null, List.of("a".repeat(500)), null, 0, "긴 검색어") // 매우 긴 검색어 + ); + } + + @ParameterizedTest(name = "제목 검색 테스트 : {index} - {0}") + @MethodSource("provideTestCases") + void parameterizedTitleSearchTest(TestCase testCase) { + // given + PickCommand.Search search = new PickCommand.Search( + user1.getId(), + testCase.folderIdList, + testCase.searchTokenList, + testCase.tagIdList, + 0L, + 30 + ); + + // when + Slice pickList = pickSearchService.searchPick(search); + + // then + assertThat(pickList).isNotNull(); + assertThat(pickList.getNumberOfElements()).isEqualTo(testCase.expectedCount); + + if (testCase.expectedCount > 0) { + for (PickResult.Pick pick : pickList) { + boolean containsAllTokens = testCase.searchTokenList.stream() + .allMatch(token -> pick.title().toLowerCase().contains(token.toLowerCase())); + assertThat(containsAllTokens).isTrue(); + } + } + } + } + + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @Nested + @DisplayName("폴더 검색 테스트") + @Transactional + class FolderSearchConditionTest { + + @Test + @DisplayName("루트 폴더 - 검색 불가") + void rootFolderSearchTest() { + // given + List folderIdList = List.of(root1.getId()); + List searchTokenList = null; + List tagIdList = null; + + PickCommand.Search search = new PickCommand.Search(user1.getId(), folderIdList, searchTokenList, tagIdList, + 0L, 30); + + // when, then + assertThatThrownBy(() -> pickSearchService.searchPick(search)) + .isInstanceOf(ApiFolderException.class) + .hasMessageStartingWith(ApiFolderException.ROOT_FOLDER_SEARCH_NOT_ALLOWED().getMessage()); + } + + @ParameterizedTest(name = "폴더 검색 테스트 : {index} - {0}") + @MethodSource("provideFolderSearchTestCases") + void parameterizedFolderSearchTest(TestCase testCase) { + // given + PickCommand.Search search = new PickCommand.Search( + user1.getId(), + testCase.folderIdList, + testCase.searchTokenList, + testCase.tagIdList, + 0L, + 30 + ); + + // when + Slice pickList = pickSearchService.searchPick(search); + + // then + assertThat(pickList).isNotNull(); + assertThat(pickList.getNumberOfElements()).isEqualTo(testCase.expectedCount); + + if (testCase.expectedCount > 0) { + for (PickResult.Pick pick : pickList) { + assertThat(pick.parentFolderId()).isEqualTo(testCase.folderIdList.get(0)); + } + } + } + + @MethodSource("provideFolderSearchTestCases") + Stream provideFolderSearchTestCases() { + return Stream.of( + new TestCase(List.of(unclassified1.getId()), null, null, 20, "미분류 폴더"), + new TestCase(List.of(general1.getId()), null, null, 10, "일반 폴더 - React.js"), + new TestCase(List.of(recycleBin1.getId()), null, null, 0, "휴지통 폴더") + ); + } + } + + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @Nested + @DisplayName("태그 검색 테스트") + @Transactional + class TagSearchConditionTest { + + @ParameterizedTest(name = "태그 검색 테스트 : {index} - {0}") + @MethodSource("provideTagSearchTestCases") + void parameterizedTagSearchTest(TestCase testCase) { + // given + PickCommand.Search search = new PickCommand.Search( + user1.getId(), + testCase.folderIdList, + testCase.searchTokenList, + testCase.tagIdList, + 0L, + 30 + ); + + // when + Slice pickList = pickSearchService.searchPick(search); + + // then + assertThat(pickList).isNotNull(); + assertThat(pickList.getNumberOfElements()).isEqualTo(testCase.expectedCount); + + if (testCase.expectedCount > 0) { + for (PickResult.Pick pick : pickList) { + assertThat(pick.tagIdOrderedList()).containsAll(testCase.tagIdList); + } + } + } + + @MethodSource("provideTagSearchTestCases") + Stream provideTagSearchTestCases() { + return Stream.of( + new TestCase(null, null, List.of(tag1.getId()), 30, "태그 1"), + new TestCase(null, null, List.of(tag2.getId()), 20, "태그 2"), + new TestCase(null, null, List.of(tag3.getId()), 20, "태그 3"), + new TestCase(null, null, List.of(tag1.getId(), tag2.getId()), 20, "태그 1, 2"), + new TestCase(null, null, List.of(tag1.getId(), tag3.getId()), 20, "태그 1, 3"), + new TestCase(null, null, List.of(tag2.getId(), tag3.getId()), 10, "태그 2, 3"), + new TestCase(null, null, List.of(tag1.getId(), tag2.getId(), tag3.getId()), 10, "태그 1, 2, 3") + ); + } + } + + @Nested + @DisplayName("존재 여부 및 예외 테스트") + class exceptionTest { + + @Test + @DisplayName("존재하지 않는 폴더") + void exceptionTest1() { + // given + List folderIdList = List.of(999L); + List searchTokenList = null; + List tagIdList = null; + + PickCommand.Search search = new PickCommand.Search(user1.getId(), folderIdList, searchTokenList, tagIdList, + 0L, 30); + + // when, then + assertThatThrownBy(() -> pickSearchService.searchPick(search)) + .isInstanceOf(ApiFolderException.class) + .hasMessageStartingWith(ApiFolderException.FOLDER_NOT_FOUND().getMessage()); + } + + @Test + @DisplayName("존재하지 않는 제목") + void exceptionTest2() { + // given + List folderIdList = null; + List searchTokenList = List.of("검색결과가없음"); + List tagIdList = null; + + PickCommand.Search search = new PickCommand.Search(user1.getId(), folderIdList, searchTokenList, tagIdList, + 0L, 30); + + // when + Slice pickList = pickSearchService.searchPick(search); + + // then + assertThat(pickList).isNotNull(); + assertThat(pickList.getNumberOfElements()).isEqualTo(0); // 검색 결과 수 + } + + @Test + @DisplayName("존재하지 않는 제목, 태그") + void exceptionTest3() { + // given + List folderIdList = null; + List searchTokenList = List.of("검색결과가없음"); + List tagIdList = List.of(999L); + + PickCommand.Search search = new PickCommand.Search(user1.getId(), folderIdList, searchTokenList, tagIdList, + 0L, 30); + + // when, then + assertThatThrownBy(() -> pickSearchService.searchPick(search)) + .isInstanceOf(ApiTagException.class) + .hasMessageStartingWith(ApiTagException.TAG_NOT_FOUND().getMessage()); + } + + @Test + @DisplayName("다른 유저의 폴더 리스트 검색") + void exceptionTest4() { + // given + List folderIdList = List.of(unclassified2.getId()); + List searchTokenList = null; + List tagIdList = null; + + PickCommand.Search search = new PickCommand.Search(user1.getId(), folderIdList, searchTokenList, tagIdList, + 0L, 30); + + // when, then + assertThatThrownBy(() -> pickSearchService.searchPick(search)) + .isInstanceOf(ApiFolderException.class) + .hasMessageStartingWith(ApiFolderException.FOLDER_ACCESS_DENIED().getMessage()); + } + + @Test + @DisplayName("다른 유저의 태그 리스트 검색") + void exceptionTest5() { + // given + List folderIdList = null; + List searchTokenList = null; + List tagIdList = List.of(tag4.getId()); + + PickCommand.Search search = new PickCommand.Search(user1.getId(), folderIdList, searchTokenList, tagIdList, + 0L, 30); + + // when, then + assertThatThrownBy(() -> pickSearchService.searchPick(search)) + .isInstanceOf(ApiTagException.class) + .hasMessageStartingWith(ApiTagException.UNAUTHORIZED_TAG_ACCESS().getMessage()); + } + } + + // Test Case Class + static class TestCase { + List folderIdList; + List searchTokenList; + List tagIdList; + int expectedCount; + String description; + + TestCase(List folderIdList, List searchTokenList, List tagIdList, int expectedCount, + String description) { + this.folderIdList = folderIdList; + this.searchTokenList = searchTokenList; + this.tagIdList = tagIdList; + this.expectedCount = expectedCount; + this.description = description; + } + + @Override + public String toString() { + return description; + } + } + + // Test Data Setting Method + private void saveUser1TestPick() { + for (int i = 0; i < 10; i++) { + LinkInfo linkInfo = new LinkInfo("리액트" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); + PickCommand.Create command = new PickCommand.Create(user1.getId(), "리액트" + i + "서버" + i + "스프링", + List.of(tag1.getId(), tag2.getId()), unclassified1.getId(), linkInfo); + pickService.saveNewPick(command); + } + + for (int i = 0; i < 10; i++) { + LinkInfo linkInfo = new LinkInfo("스프링" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); + PickCommand.Create command = new PickCommand.Create(user1.getId(), "스프링" + i, + List.of(tag1.getId(), tag2.getId(), tag3.getId()), unclassified1.getId(), linkInfo); + pickService.saveNewPick(command); + } + + for (int i = 0; i < 5; i++) { + LinkInfo linkInfo = new LinkInfo("Spring" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); + PickCommand.Create command = new PickCommand.Create(user1.getId(), "Spring" + i, + List.of(tag1.getId(), tag3.getId()), general1.getId(), linkInfo); + pickService.saveNewPick(command); + } + + for (int i = 0; i < 5; i++) { + LinkInfo linkInfo = new LinkInfo("Test" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); + PickCommand.Create command = new PickCommand.Create(user1.getId(), "Test" + i, + List.of(tag1.getId(), tag3.getId()), general1.getId(), linkInfo); + pickService.saveNewPick(command); + } + } + + private void saveUser2TestPick() { + for (int i = 0; i < 5; i++) { + LinkInfo linkInfo = new LinkInfo("user2" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); + PickCommand.Create command = new PickCommand.Create(user2.getId(), "Backend", + List.of(tag4.getId(), tag5.getId()), unclassified2.getId(), linkInfo); + pickService.saveNewPick(command); + } + + for (int i = 0; i < 5; i++) { + LinkInfo linkInfo = new LinkInfo("user2s" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); + PickCommand.Create command = new PickCommand.Create(user2.getId(), "Frontend", + List.of(tag6.getId()), unclassified2.getId(), linkInfo); + pickService.saveNewPick(command); + } + } + + private void saveTestTag(TagRepository tagRepository) { + // save test tag + tag1 = tagRepository.save(Tag.builder().user(user1).name("tag1").colorNumber(1).build()); + tag2 = tagRepository.save(Tag.builder().user(user1).name("tag2").colorNumber(1).build()); + tag3 = tagRepository.save(Tag.builder().user(user1).name("tag3").colorNumber(1).build()); + + tag4 = tagRepository.save(Tag.builder().user(user2).name("tag4").colorNumber(1).build()); + tag5 = tagRepository.save(Tag.builder().user(user2).name("tag5").colorNumber(1).build()); + tag6 = tagRepository.save(Tag.builder().user(user2).name("tag6").colorNumber(1).build()); + } + + private void saveTestFolder(FolderRepository folderRepository) { + // save test folder + root1 = folderRepository.save(Folder.createEmptyRootFolder(user1)); + recycleBin1 = folderRepository.save(Folder.createEmptyRecycleBinFolder(user1)); + unclassified1 = folderRepository.save(Folder.createEmptyUnclassifiedFolder(user1)); + general1 = folderRepository.save(Folder.createEmptyGeneralFolder(user1, root1, "React.js")); + + root2 = folderRepository.save(Folder.createEmptyRootFolder(user2)); + recycleBin2 = folderRepository.save(Folder.createEmptyRecycleBinFolder(user2)); + unclassified2 = folderRepository.save(Folder.createEmptyUnclassifiedFolder(user2)); + general2 = folderRepository.save(Folder.createEmptyGeneralFolder(user2, root2, "Backend")); + } + + private void saveTestUser(UserRepository userRepository) { + // save test user + user1 = userRepository.save( + User.builder() + .email("user1@test.com") + .nickname("user1") + .password("user1") + .role(Role.ROLE_USER) + .socialProvider(SocialType.KAKAO) + .socialProviderId("1") + .tagOrderList(new ArrayList<>()) + .build() + ); + + // save test user + user2 = userRepository.save( + User.builder() + .email("user2@test.com") + .nickname("user2") + .password("user2") + .role(Role.ROLE_USER) + .socialProvider(SocialType.KAKAO) + .socialProviderId("2") + .tagOrderList(new ArrayList<>()) + .build() + ); + } +} \ No newline at end of file diff --git a/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickServiceTest.java b/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickServiceTest.java index 0446d9f3b..185dd2d37 100644 --- a/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickServiceTest.java +++ b/backend/techpick-api/src/test/java/techpick/api/domain/pick/service/PickServiceTest.java @@ -177,14 +177,6 @@ void folder_list_in_pick_list_test() { assertThat(folderPickList.get(0).pickList().size()).isEqualTo(3); // unclassified assertThat(folderPickList.get(1).pickList().size()).isEqualTo(1); // general assertThat(folderPickList.get(2).pickList().size()).isEqualTo(1); // recycleBin - - assertThat(folderPickList.get(0).pickList().get(0).id()).isEqualTo(pick2.id()); // unclassified - assertThat(folderPickList.get(0).pickList().get(1).id()).isEqualTo(pick3.id()); - assertThat(folderPickList.get(0).pickList().get(2).id()).isEqualTo(pick5.id()); - - assertThat(folderPickList.get(1).pickList().get(0).id()).isEqualTo(pick4.id()); // general - - assertThat(folderPickList.get(2).pickList().get(0).id()).isEqualTo(pick1.id()); // recycleBin } } @@ -294,7 +286,7 @@ void update_data_with_null_test() { String newTitle = "NEW_PICK"; List newTagOrder = List.of(tag3.getId(), tag2.getId(), tag1.getId()); PickCommand.Update updateCommand = new PickCommand.Update( - user.getId(), savePick.id(), newTitle, newTagOrder + user.getId(), savePick.id(), newTitle, null, newTagOrder ); PickResult.Pick updatePick = pickService.updatePick(updateCommand); @@ -431,8 +423,7 @@ void move_root_pick_test() { @Test @DisplayName(""" - 1. 순서 설정값이 음수가 들어오면 예외를 발생시킨다. - 2. 순서 설정값이 전체 길이보다 큰 값이 들어오면 예외를 발생시킨다. + 순서 id 리스트가 존재하지 않는 픽 Id면 ApiPickException.PICK_NOT_FOUND() 예외를 발생시킨다. """) void move_pick_invalid_order_value_test() { // given @@ -448,7 +439,7 @@ void move_pick_invalid_order_value_test() { // when, then assertThatThrownBy(() -> pickService.movePick(command)) - .isInstanceOf(IndexOutOfBoundsException.class); + .isInstanceOf(ApiPickException.class); } } diff --git a/backend/techpick-api/src/test/java/techpick/api/fixture/PickFixture.java b/backend/techpick-api/src/test/java/techpick/api/fixture/PickFixture.java index ea85f816c..8fa80c020 100644 --- a/backend/techpick-api/src/test/java/techpick/api/fixture/PickFixture.java +++ b/backend/techpick-api/src/test/java/techpick/api/fixture/PickFixture.java @@ -26,7 +26,7 @@ public class PickFixture { private Folder parentFolder; - private String title = ""; + private String title; private List tagIdOrderedList; diff --git a/backend/techpick-api/src/test/java/techpick/api/infrastructure/pick/PickQueryTest.java b/backend/techpick-api/src/test/java/techpick/api/infrastructure/pick/PickQueryTest.java deleted file mode 100644 index 6cc4198aa..000000000 --- a/backend/techpick-api/src/test/java/techpick/api/infrastructure/pick/PickQueryTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package techpick.api.infrastructure.pick; - -import java.util.ArrayList; -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.domain.Slice; -import org.springframework.test.context.ActiveProfiles; - -import lombok.extern.slf4j.Slf4j; -import techpick.TechPickApiApplication; -import techpick.api.domain.link.dto.LinkInfo; -import techpick.api.domain.pick.dto.PickCommand; -import techpick.api.domain.pick.dto.PickResult; -import techpick.api.domain.pick.service.PickService; -import techpick.core.model.folder.Folder; -import techpick.core.model.folder.FolderRepository; -import techpick.core.model.pick.Pick; -import techpick.core.model.tag.Tag; -import techpick.core.model.tag.TagRepository; -import techpick.core.model.user.Role; -import techpick.core.model.user.SocialType; -import techpick.core.model.user.User; -import techpick.core.model.user.UserRepository; - -@Slf4j -@SpringBootTest(classes = TechPickApiApplication.class) -@ActiveProfiles("local") -class PickQueryTest { - - @Autowired - private PickQuery pickQuery; - - @Autowired - PickService pickService; - - User user; - Folder root, recycleBin, unclassified, general; - Tag tag1, tag2, tag3; - - @BeforeEach - void setUp( - @Autowired UserRepository userRepository, - @Autowired FolderRepository folderRepository, - @Autowired TagRepository tagRepository - ) { - // save test user - user = userRepository.save( - User.builder() - .email("test@test.com") - .nickname("test") - .password("test") - .role(Role.ROLE_USER) - .socialProvider(SocialType.KAKAO) - .socialProviderId("1") - .tagOrderList(new ArrayList<>()) - .build() - ); - - // save test folder - root = folderRepository.save(Folder.createEmptyRootFolder(user)); - recycleBin = folderRepository.save(Folder.createEmptyRecycleBinFolder(user)); - unclassified = folderRepository.save(Folder.createEmptyUnclassifiedFolder(user)); - general = folderRepository.save(Folder.createEmptyGeneralFolder(user, root, "React.js")); - - // save tag - tag1 = tagRepository.save(Tag.builder().user(user).name("tag1").colorNumber(1).build()); - tag2 = tagRepository.save(Tag.builder().user(user).name("tag2").colorNumber(1).build()); - tag3 = tagRepository.save(Tag.builder().user(user).name("tag3").colorNumber(1).build()); - - for (int i = 0; i < 10; i++) { - LinkInfo linkInfo = new LinkInfo("리액트" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); - PickCommand.Create command = new PickCommand.Create(user.getId(), "리액트" + i + "서버" + i + "스프링", - List.of(tag1.getId(), tag2.getId()), - unclassified.getId(), linkInfo); - pickService.saveNewPick(command); - } - - for (int i = 0; i < 10; i++) { - LinkInfo linkInfo = new LinkInfo("스프링" + i, "링크 제목", "링크 설명", "링크 이미지 url", null); - PickCommand.Create command = new PickCommand.Create(user.getId(), "스프링" + i, - List.of(tag1.getId(), tag2.getId(), tag3.getId()), - unclassified.getId(), linkInfo); - pickService.saveNewPick(command); - } - } - - @Test - void test() { - List pickIdList = List.of(2L, 1L, 3L); - List pickList = pickQuery.getPickList(user.getId(), pickIdList); - for (PickResult.Pick pick : pickList) { - log.info("pick : {}", pick); - } - } - - @Test - void searchTest() { - // 검색 1 - List folderIdList1 = List.of(unclassified.getId(), recycleBin.getId(), root.getId()); - List searchTokenList1 = List.of("리액트", "서버", "스프링"); - List tagIdList1 = List.of(tag1.getId(), tag3.getId()); - - // 해당하는 태그가 없기 때문에 출력 값이 없음. - Slice pickList1 = pickQuery.searchPick(user.getId(), folderIdList1, searchTokenList1, - tagIdList1, 0L, 20); - for (PickResult.Pick pick : pickList1.getContent()) { - log.info("search1 : {}", pick); - } - - // ------------------------------------------------------------------------------------ - - // 검색 2 - List folderIdList2 = List.of(unclassified.getId()); - List searchTokenList2 = List.of("스프링"); - List tagIdList2 = List.of(tag1.getId()); - - Slice pickList2 = pickQuery.searchPick(user.getId(), folderIdList2, searchTokenList2, - tagIdList2, 0L, 20); - for (PickResult.Pick pick : pickList2.getContent()) { - log.info("search2 : {}", pick); - } - - // ------------------------------------------------------------------------------------ - - // 검색 3 - List folderIdList3 = List.of(unclassified.getId()); - List searchTokenList3 = List.of("스 링"); - List tagIdList3 = List.of(tag1.getId()); - - Slice pickList3 = pickQuery.searchPick(user.getId(), folderIdList3, searchTokenList3, - tagIdList3, 0L, 20); - for (PickResult.Pick pick : pickList3.getContent()) { - log.info("search3 : {}", pick); - } - - // ------------------------------------------------------------------------------------ - - // 검색 4 - pickId, search, tag null - List folderIdList4 = new ArrayList<>(); - List searchTokenList4 = new ArrayList<>(); - List tagIdList4 = new ArrayList<>(); - - Slice pickList4 = pickQuery.searchPick(user.getId(), folderIdList4, searchTokenList4, - tagIdList4, 0L, 20); - for (PickResult.Pick pick : pickList4.getContent()) { - log.info("search4 : {}", pick); - } - - // ------------------------------------------------------------------------------------ - - // 검색 5 - search만 not null - List folderIdList5 = new ArrayList<>(); - List searchTokenList5 = List.of("리액트"); - List tagIdList5 = new ArrayList<>(); - - Slice pickList5 = pickQuery.searchPick(user.getId(), folderIdList5, searchTokenList5, - tagIdList5, 0L, 20); - for (PickResult.Pick pick : pickList5.getContent()) { - log.info("search5 : {}", pick); - } - } -} \ No newline at end of file diff --git a/backend/techpick-core/src/main/java/techpick/core/model/folder/FolderType.java b/backend/techpick-core/src/main/java/techpick/core/model/folder/FolderType.java index 84ca177a0..e5971c61c 100644 --- a/backend/techpick-core/src/main/java/techpick/core/model/folder/FolderType.java +++ b/backend/techpick-core/src/main/java/techpick/core/model/folder/FolderType.java @@ -4,10 +4,10 @@ public enum FolderType { - UNCLASSIFIED("미분류 폴더"), - RECYCLE_BIN("휴지통 폴더"), - ROOT("루트 폴더"), - GENERAL("일반 폴더"), + UNCLASSIFIED("미분류"), + RECYCLE_BIN("휴지통"), + ROOT("루트"), + GENERAL("일반"), ; private final String label;